derived presence state (#1204)

This PR adds

- A new `TLInstancePresence` record type, to collect info about the
presence state in a particular instance of the editor. This will
eventually be used to sync presence data instead of sending
instance-only state across the wire.
- **Record Scopes**

`RecordType` now has a `scope` property which can be one of three
things:
- `document`: the record belongs to the document and should be synced
and persisted freely. Currently: `TLDocument`, `TLPage`, `TLShape`, and
`TLAsset`
- `instance`: the record belongs to a single instance of the store and
should not be synced at all. It should not be persisted directly in most
cases, but rather compiled into a kind of 'instance configuration' to
store alongside the local document data so that when reopening the
associated document it can remember some of the previous instance state.
Currently: `TLInstance`, `TLInstancePageState`, `TLCamera`, `TLUser`,
`TLUserDocument`, `TLUserPresence`
- `presence`: the record belongs to a single instance of the store and
should not be persisted, but may be synced using the special presence
sync protocol. Currently just `TLInstancePresence`

This sets us up for the following changes, which are gonna be pretty
high-impact in terms of integrating tldraw into existing systems:

- Removing `instanceId` as a config option. Each instance gets a
randomly generated ID.
- We'd replace it with an `instanceConfig` option that has stuff like
selectedIds, camera positions, and so on. Then it's up to library users
to get and reinstate the instance config at persistence boundaries.
- Removing `userId` as config option, and removing the `TLUser` type
altogether.
- We might need to revisit when doing auth-enabled features like locking
shapes, but I suspect that will be separate.
This commit is contained in:
David Sheldrick 2023-04-27 19:03:19 +01:00 committed by GitHub
parent da613ea6ef
commit 731da1bc77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 396 additions and 93 deletions

View file

@ -49,11 +49,10 @@ export function generateSharedTasks(bublic: '<rootDir>' | '<rootDir>/bublic') {
cache: {
inputs: {
include: [
'{.,./bublic}/packages/*/src/**/*.{ts,tsx}',
'{.,./bublic}/{apps,scripts,e2e}/**/*.{ts,tsx}',
'{.,./bublic}/{apps,packages}/*/tsconfig.json',
'{.,./bublic}/{scripts,e2e}/tsconfig.json',
`${bublic}/config/tsconfig.base.json`,
'{,bublic/}packages/*/src/**/*.{ts,tsx}',
'{,bublic/}{apps,scripts,e2e}/**/*.{ts,tsx}',
'{,bublic/}{apps,packages}/*/tsconfig.json',
'{,bublic/}{scripts,e2e}/tsconfig.json',
],
exclude: ['**/dist*/**/*.d.ts'],
},
@ -95,7 +94,7 @@ export function generateSharedTasks(bublic: '<rootDir>' | '<rootDir>/bublic') {
baseCommand: `tsx ${bublic}/scripts/api-check.ts`,
runsAfter: { 'build:api': {} },
cache: {
inputs: ['**/api/bublic.d.ts'],
inputs: [`${bublic}/packages/*/api/public.d.ts`],
},
},
} satisfies LazyConfig['tasks']

View file

@ -87,8 +87,8 @@
},
"devDependencies": {
"@microsoft/api-extractor": "^7.34.1",
"@swc/core": "^1.3.41",
"@swc/jest": "^0.2.24",
"@swc/core": "^1.3.55",
"@swc/jest": "^0.2.26",
"@types/glob": "^8.1.0",
"auto": "^10.44.0",
"fs-extra": "^11.1.0",

View file

@ -48,8 +48,6 @@
"@tldraw/utils": "workspace:*"
},
"devDependencies": {
"@swc/core": "^1.2.204",
"@swc/jest": "^0.2.21",
"lazyrepo": "0.0.0-alpha.22",
"ts-node-dev": "^1.1.8"
},

View file

@ -31,6 +31,7 @@ import { SelectionCorner } from '@tldraw/primitives';
import { SelectionEdge } from '@tldraw/primitives';
import { SelectionHandle } from '@tldraw/primitives';
import { SerializedSchema } from '@tldraw/tlstore';
import { Signal } from 'signia';
import { StoreSchema } from '@tldraw/tlstore';
import { StoreSnapshot } from '@tldraw/tlstore';
import { StoreValidator } from '@tldraw/tlstore';
@ -62,6 +63,7 @@ import { TLImageShape } from '@tldraw/tlschema';
import { TLInstance } from '@tldraw/tlschema';
import { TLInstanceId } from '@tldraw/tlschema';
import { TLInstancePageState } from '@tldraw/tlschema';
import { TLInstancePresence } from '@tldraw/tlschema';
import { TLInstancePropsForNextShape } from '@tldraw/tlschema';
import { TLLineShape } from '@tldraw/tlschema';
import { TLNoteShape } from '@tldraw/tlschema';
@ -1797,10 +1799,11 @@ export function TldrawEditor(props: TldrawEditorProps): JSX.Element;
// @public (undocumented)
export class TldrawEditorConfig {
constructor({ shapes, tools, allowUnknownShapes, }: {
constructor(args: {
shapes?: readonly TLShapeDef<any, any>[];
tools?: readonly StateNodeConstructor[];
allowUnknownShapes?: boolean;
derivePresenceState?: (store: TLStore) => Signal<null | TLInstancePresence>;
});
// (undocumented)
createStore(config: {

View file

@ -1,6 +1,6 @@
require('fake-indexeddb/auto')
global.ResizeObserver = require('resize-observer-polyfill')
global.crypto = new (require('@peculiar/webcrypto').Crypto)()
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
global.FontFace = class FontFace {
load() {
return Promise.resolve()

View file

@ -1,16 +1,13 @@
import {
CLIENT_FIXUP_SCRIPT,
ensureStoreIsUsable,
onValidationFailure,
rootShapeTypeMigrations,
storeMigrations,
TLAsset,
TLCamera,
TLDocument,
TLDOCUMENT_ID,
TLDocument,
TLInstance,
TLInstanceId,
TLInstancePageState,
TLInstancePresence,
TLPage,
TLRecord,
TLShape,
@ -20,16 +17,21 @@ import {
TLUserDocument,
TLUserId,
TLUserPresence,
ensureStoreIsUsable,
onValidationFailure,
rootShapeTypeMigrations,
storeMigrations,
} from '@tldraw/tlschema'
import {
createRecordType,
defineMigrations,
RecordType,
Store,
StoreSchema,
StoreSnapshot,
createRecordType,
defineMigrations,
} from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { Signal } from 'signia'
import { TLArrowShapeDef } from '../app/shapeutils/TLArrowUtil/TLArrowUtil'
import { TLBookmarkShapeDef } from '../app/shapeutils/TLBookmarkUtil/TLBookmarkUtil'
import { TLDrawShapeDef } from '../app/shapeutils/TLDrawUtil/TLDrawUtil'
@ -44,6 +46,7 @@ import { TLTextShapeDef } from '../app/shapeutils/TLTextUtil/TLTextUtil'
import { TLVideoShapeDef } from '../app/shapeutils/TLVideoUtil/TLVideoUtil'
import { StateNodeConstructor } from '../app/statechart/StateNode'
import { TLShapeDef, TLUnknownShapeDef } from './TLShapeDefinition'
import { defaultDerivePresenceState } from './defaultDerivePresenceState'
const CORE_SHAPE_DEFS = () =>
[
@ -70,15 +73,19 @@ export class TldrawEditorConfig {
readonly TLShape: RecordType<TLShape, 'type' | 'props' | 'index' | 'parentId'>
readonly tools: readonly StateNodeConstructor[]
constructor({
shapes = [],
tools = [],
allowUnknownShapes = false,
}: {
constructor(args: {
shapes?: readonly TLShapeDef<any, any>[]
tools?: readonly StateNodeConstructor[]
allowUnknownShapes?: boolean
/** @internal */
derivePresenceState?: (store: TLStore) => Signal<TLInstancePresence | null>
}) {
const {
shapes = [],
tools = [],
allowUnknownShapes = false,
derivePresenceState = defaultDerivePresenceState,
} = args
this.tools = tools
const allShapeDefs = [...CORE_SHAPE_DEFS(), ...shapes]
@ -110,6 +117,7 @@ export class TldrawEditorConfig {
const shapeRecord = createRecordType<TLShape>('shape', {
migrations: shapeTypeMigrations,
validator: T.model('shape', shapeValidator),
scope: 'document',
}).withDefaultProperties(() => ({ x: 0, y: 0, rotation: 0, isLocked: false }))
this.TLShape = shapeRecord
@ -125,11 +133,13 @@ export class TldrawEditorConfig {
user: TLUser,
user_document: TLUserDocument,
user_presence: TLUserPresence,
instance_presence: TLInstancePresence,
},
{
snapshotMigrations: storeMigrations,
onValidationFailure,
ensureStoreIsUsable,
derivePresenceState,
}
)
}

View file

@ -0,0 +1,56 @@
import { TLInstancePresence, TLStore } from '@tldraw/tlschema'
import { Signal, computed } from 'signia'
/** @internal */
export const defaultDerivePresenceState = (store: TLStore): Signal<TLInstancePresence | null> => {
const $instance = store.query.record('instance', () => ({
id: { eq: store.props.instanceId },
}))
const $user = store.query.record('user', () => ({ id: { eq: store.props.userId } }))
const $userPresence = store.query.record('user_presence', () => ({
userId: { eq: store.props.userId },
}))
const $pageState = store.query.record('instance_page_state', () => ({
instanceId: { eq: store.props.instanceId },
pageId: { eq: $instance.value?.currentPageId ?? ('' as any) },
}))
const $camera = store.query.record('camera', () => ({
id: { eq: $pageState.value?.cameraId ?? ('' as any) },
}))
return computed('instancePresence', () => {
const pageState = $pageState.value
const instance = $instance.value
const user = $user.value
const userPresence = $userPresence.value
const camera = $camera.value
if (!pageState || !instance || !user || !userPresence || !camera) {
return null
}
return TLInstancePresence.create({
id: TLInstancePresence.createCustomId(store.props.instanceId),
instanceId: store.props.instanceId,
selectedIds: pageState.selectedIds,
brush: instance.brush,
scribble: instance.scribble,
userId: store.props.userId,
userName: user.name,
followingUserId: instance.followingUserId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
color: userPresence.color,
currentPageId: instance.currentPageId,
cursor: {
x: userPresence.cursor.x,
y: userPresence.cursor.y,
rotation: instance.cursor.rotation,
type: instance.cursor.type,
},
lastActivityTimestamp: userPresence.lastActivityTimestamp,
screenBounds: instance.screenBounds,
})
})
}

View file

@ -1,7 +1,7 @@
require('fake-indexeddb/auto')
require('jest-canvas-mock')
global.ResizeObserver = require('resize-observer-polyfill')
global.crypto = new (require('@peculiar/webcrypto').Crypto)()
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
global.FontFace = class FontFace {
load() {
return Promise.resolve()

View file

@ -1011,6 +1011,48 @@ export const TLInstancePageState: RecordType<TLInstancePageState, "cameraId" | "
// @public (undocumented)
export type TLInstancePageStateId = ID<TLInstancePageState>;
// @public (undocumented)
export interface TLInstancePresence extends BaseRecord<'instance_presence'> {
// (undocumented)
brush: Box2dModel | null;
// (undocumented)
camera: {
x: number;
y: number;
z: number;
};
// (undocumented)
color: string;
// (undocumented)
currentPageId: TLPageId;
// (undocumented)
cursor: {
x: number;
y: number;
type: TLCursor['type'];
rotation: number;
};
// (undocumented)
followingUserId: null | TLUserId;
// (undocumented)
instanceId: TLInstanceId;
// (undocumented)
lastActivityTimestamp: number;
// (undocumented)
screenBounds: Box2dModel;
// (undocumented)
scribble: null | TLScribble;
// (undocumented)
selectedIds: TLShapeId[];
// (undocumented)
userId: TLUserId;
// (undocumented)
userName: string;
}
// @public (undocumented)
export const TLInstancePresence: RecordType<TLInstancePresence, "brush" | "camera" | "color" | "currentPageId" | "cursor" | "followingUserId" | "instanceId" | "lastActivityTimestamp" | "screenBounds" | "scribble" | "selectedIds" | "userId" | "userName">;
// @public (undocumented)
export type TLInstancePropsForNextShape = Pick<TLShapeProps, TLStyleType>;
@ -1078,7 +1120,7 @@ export type TLPageId = ID<TLPage>;
export type TLParentId = TLPageId | TLShapeId;
// @public (undocumented)
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLPage | TLShape | TLUser | TLUserDocument | TLUserPresence;
export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLShape | TLUser | TLUserDocument | TLUserPresence;
// @public (undocumented)
export type TLScribble = {

View file

@ -3,6 +3,7 @@ import { TLCamera } from './records/TLCamera'
import { TLDocument } from './records/TLDocument'
import { TLInstance } from './records/TLInstance'
import { TLInstancePageState } from './records/TLInstancePageState'
import { TLInstancePresence } from './records/TLInstancePresence'
import { TLPage } from './records/TLPage'
import { TLShape } from './records/TLShape'
import { TLUser } from './records/TLUser'
@ -21,3 +22,4 @@ export type TLRecord =
| TLUser
| TLUserDocument
| TLUserPresence
| TLInstancePresence

View file

@ -59,6 +59,7 @@ export {
instancePageStateTypeValidator,
type TLInstancePageStateId,
} from './records/TLInstancePageState'
export { TLInstancePresence } from './records/TLInstancePresence'
export { TLPage, pageTypeMigrations, pageTypeValidator, type TLPageId } from './records/TLPage'
export {
createCustomShapeId,

View file

@ -172,6 +172,7 @@ describe('TLImageAsset AddIsAnimated', () => {
const ShapeRecord = createRecordType('shape', {
validator: { validate: (record) => record as TLShape },
scope: 'document',
})
describe('Store removing Icon and Code shapes', () => {
@ -634,6 +635,24 @@ describe('Add crop=null to image shapes', () => {
})
})
describe('Adding instance_presence to the schema', () => {
const { up, down } = storeMigrations.migrators[2]
test('up works as expected', () => {
expect(up({})).toEqual({})
})
test('down works as expected', () => {
expect(
down({
'instance_presence:123': { id: 'instance_presence:123', typeName: 'instance_presence' },
'instance:123': { id: 'instance:123', typeName: 'instance' },
})
).toEqual({
'instance:123': { id: 'instance:123', typeName: 'instance' },
})
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) {

View file

@ -60,6 +60,7 @@ export type TLAssetPartial<T extends TLAsset = TLAsset> = T extends T
export const TLAsset = createRecordType<TLAsset>('asset', {
migrations: assetTypeMigrations,
validator: assetTypeValidator,
scope: 'document',
})
/** @public */

View file

@ -49,6 +49,7 @@ export const cameraTypeMigrations = defineMigrations({
export const TLCamera = createRecordType<TLCamera>('camera', {
migrations: cameraTypeMigrations,
validator: cameraTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLCamera, 'id' | 'typeName'> => ({
x: 0,

View file

@ -41,6 +41,7 @@ export const documentTypeMigrations = defineMigrations({
export const TLDocument = createRecordType<TLDocument>('document', {
migrations: documentTypeMigrations,
validator: documentTypeValidator,
scope: 'document',
}).withDefaultProperties(
(): Omit<TLDocument, 'id' | 'typeName'> => ({
gridSize: 10,

View file

@ -213,6 +213,7 @@ export const instanceTypeMigrations = defineMigrations({
export const TLInstance = createRecordType<TLInstance>('instance', {
migrations: instanceTypeMigrations,
validator: instanceTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLInstance, 'typeName' | 'id' | 'userId' | 'currentPageId'> => ({
followingUserId: null,

View file

@ -74,6 +74,7 @@ export const instancePageStateMigrations = defineMigrations({
export const TLInstancePageState = createRecordType<TLInstancePageState>('instance_page_state', {
migrations: instancePageStateMigrations,
validator: instancePageStateTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<
TLInstancePageState,

View file

@ -0,0 +1,89 @@
import { BaseRecord, createRecordType, defineMigrations, ID } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { Box2dModel } from '../geometry-types'
import { cursorTypeValidator, scribbleTypeValidator, TLCursor, TLScribble } from '../ui-types'
import { idValidator, userIdValidator } from '../validation'
import { TLInstanceId } from './TLInstance'
import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape'
import { TLUserId } from './TLUser'
/** @public */
export interface TLInstancePresence extends BaseRecord<'instance_presence'> {
instanceId: TLInstanceId
userId: TLUserId
userName: string
lastActivityTimestamp: number
color: string // can be any hex color
camera: { x: number; y: number; z: number }
selectedIds: TLShapeId[]
currentPageId: TLPageId
brush: Box2dModel | null
scribble: TLScribble | null
screenBounds: Box2dModel
followingUserId: TLUserId | null
cursor: {
x: number
y: number
type: TLCursor['type']
rotation: number
}
}
/** @public */
export type TLInstancePresenceID = ID<TLInstancePresence>
// --- VALIDATION ---
/** @public */
export const instancePresenceTypeValidator: T.Validator<TLInstancePresence> = T.model(
'instance_presence',
T.object({
instanceId: idValidator<TLInstanceId>('instance'),
typeName: T.literal('instance_presence'),
id: idValidator<TLInstancePresenceID>('instance_presence'),
userId: userIdValidator,
userName: T.string,
lastActivityTimestamp: T.number,
followingUserId: userIdValidator.nullable(),
cursor: T.object({
x: T.number,
y: T.number,
type: cursorTypeValidator,
rotation: T.number,
}),
color: T.string,
camera: T.object({
x: T.number,
y: T.number,
z: T.number,
}),
screenBounds: T.boxModel,
selectedIds: T.arrayOf(idValidator<TLShapeId>('shape')),
currentPageId: idValidator<TLPageId>('page'),
brush: T.boxModel.nullable(),
scribble: scribbleTypeValidator.nullable(),
})
)
// --- MIGRATIONS ---
// STEP 1: Add a new version number here, give it a meaningful name.
// It should be 1 higher than the current version
const Versions = {
Initial: 0,
} as const
export const userPresenceTypeMigrations = defineMigrations({
// STEP 2: Update the current version to point to your latest version
currentVersion: Versions.Initial,
firstVersion: Versions.Initial,
migrators: {
// STEP 3: Add an up+down migration for the new version here
},
})
/** @public */
export const TLInstancePresence = createRecordType<TLInstancePresence>('instance_presence', {
migrations: userPresenceTypeMigrations,
validator: instancePresenceTypeValidator,
scope: 'presence',
})

View file

@ -47,4 +47,5 @@ export const pageTypeMigrations = defineMigrations({
export const TLPage = createRecordType<TLPage>('page', {
migrations: pageTypeMigrations,
validator: pageTypeValidator,
scope: 'document',
})

View file

@ -48,6 +48,7 @@ export const userTypeMigrations = defineMigrations({
export const TLUser = createRecordType<TLUser>('user', {
migrations: userTypeMigrations,
validator: userTypeValidator,
scope: 'instance',
}).withDefaultProperties((): Omit<TLUser, 'id' | 'typeName'> => {
let lang
if (typeof window !== 'undefined' && window.navigator) {

View file

@ -87,6 +87,7 @@ export const userDocumentTypeMigrations = defineMigrations({
export const TLUserDocument = createRecordType<TLUserDocument>('user_document', {
migrations: userDocumentTypeMigrations,
validator: userDocumentTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLUserDocument, 'id' | 'typeName' | 'userId'> => ({
/* STEP 6: Add any new default values for properties here */

View file

@ -65,6 +65,7 @@ export const userPresenceTypeMigrations = defineMigrations({
export const TLUserPresence = createRecordType<TLUserPresence>('user_presence', {
migrations: userPresenceTypeMigrations,
validator: userPresenceTypeValidator,
scope: 'instance',
}).withDefaultProperties(
(): Omit<TLUserPresence, 'id' | 'typeName' | 'userId'> => ({
lastUsedInstanceId: null,

View file

@ -7,13 +7,14 @@ import { TLRecord } from './TLRecord'
const Versions = {
Initial: 0,
RemoveCodeAndIconShapeTypes: 1,
AddInstancePresenceType: 2,
} as const
/** @public */
export const storeMigrations = defineMigrations({
// STEP 2: Update the current version to point to your latest version
firstVersion: Versions.Initial,
currentVersion: Versions.RemoveCodeAndIconShapeTypes,
currentVersion: Versions.AddInstancePresenceType,
migrators: {
// STEP 3: Add an up+down migration for the new version here
[Versions.RemoveCodeAndIconShapeTypes]: {
@ -29,5 +30,15 @@ export const storeMigrations = defineMigrations({
return store
},
},
[Versions.AddInstancePresenceType]: {
up: (store: StoreSnapshot<TLRecord>) => {
return store
},
down: (store: StoreSnapshot<TLRecord>) => {
return Object.fromEntries(
Object.entries(store).filter(([_, v]) => v.typeName !== 'instance_presence')
)
},
},
},
})

View file

@ -6,6 +6,7 @@
import { Atom } from 'signia';
import { Computed } from 'signia';
import { Signal } from 'signia';
// @public
export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;
@ -31,7 +32,7 @@ export type CollectionDiff<T> = {
export function compareRecordVersions(a: RecordVersion, b: RecordVersion): -1 | 0 | 1;
// @public (undocumented)
export const compareSchemas: (a: SerializedSchema, b: SerializedSchema) => number;
export const compareSchemas: (a: SerializedSchema, b: SerializedSchema) => -1 | 0 | 1;
// @public
export type ComputedCache<Data, R extends BaseRecord> = {
@ -42,6 +43,7 @@ export type ComputedCache<Data, R extends BaseRecord> = {
export function createRecordType<R extends BaseRecord>(typeName: R['typeName'], config: {
migrations?: Migrations;
validator: StoreValidator<R>;
scope: Scope;
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
// @public (undocumented)
@ -158,6 +160,7 @@ export class RecordType<R extends BaseRecord, RequiredProperties extends keyof O
readonly validator?: {
validate: (r: unknown) => R;
} | StoreValidator<R>;
readonly scope?: Scope;
});
clone(record: R): R;
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R;
@ -170,6 +173,8 @@ export class RecordType<R extends BaseRecord, RequiredProperties extends keyof O
// (undocumented)
readonly migrations: Migrations;
parseId(id: string): ID<R>;
// (undocumented)
readonly scope: Scope;
readonly typeName: R['typeName'];
validate(record: unknown): R;
// (undocumented)
@ -274,6 +279,8 @@ export class StoreSchema<R extends BaseRecord, P = unknown> {
// (undocumented)
get currentStoreVersion(): number;
// @internal (undocumented)
derivePresenceState(store: Store<R, P>): Signal<null | R> | undefined;
// @internal (undocumented)
ensureStoreIsUsable(store: Store<R, P>): void;
// (undocumented)
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
@ -302,6 +309,7 @@ export type StoreSchemaOptions<R extends BaseRecord, P> = {
recordBefore: null | R;
}) => R;
ensureStoreIsUsable?: (store: Store<R, P>) => void;
derivePresenceState?: (store: Store<R, P>) => Signal<null | R>;
};
// @public

View file

@ -6,6 +6,17 @@ import { Migrations } from './migrate'
export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['create']>
/**
* Defines the scope of the record
*
* instance: The record belongs to a single instance of the store. It should not be synced, and any persistence logic should 'de-instance-ize' the record before persisting it, and apply the reverse when rehydrating.
* document: The record is persisted and synced. It is available to all store instances.
* presence: The record belongs to a single instance of the store. It may be synced to other instances, but other instances should not make changes to it. It should not be persisted.
*
* @public
* */
export type Scope = 'instance' | 'document' | 'presence'
/**
* A record type is a type that can be stored in a record store. It is created with
* `createRecordType`.
@ -20,6 +31,8 @@ export class RecordType<
readonly migrations: Migrations
readonly validator: StoreValidator<R> | { validate: (r: unknown) => R }
readonly scope: Scope
constructor(
/**
* The unique type associated with this record.
@ -32,11 +45,13 @@ export class RecordType<
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator?: StoreValidator<R> | { validate: (r: unknown) => R }
readonly scope?: Scope
}
) {
this.createDefaultProperties = config.createDefaultProperties
this.migrations = config.migrations
this.validator = config.validator ?? { validate: (r: unknown) => r as R }
this.scope = config.scope ?? 'document'
}
/**
@ -174,6 +189,7 @@ export class RecordType<
createDefaultProperties: createDefaultProperties as any,
migrations: this.migrations,
validator: this.validator,
scope: this.scope,
})
}
@ -204,12 +220,14 @@ export function createRecordType<R extends BaseRecord>(
migrations?: Migrations
// todo: optional validations
validator: StoreValidator<R>
scope: Scope
}
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
createDefaultProperties: () => ({} as any),
migrations: config.migrations ?? { currentVersion: 0, firstVersion: 0, migrators: {} },
validator: config.validator,
scope: config.scope,
})
}

View file

@ -766,15 +766,4 @@ class HistoryAccumulator<T extends BaseRecord> {
hasChanges() {
return this._history.length > 0
}
/**
* Ensure that the store is usable. A class that extends this store should override this method.
*
* @param config - The configuration object. This can be any object that allows the store to
* validate that it is usable; the extending class should specify the type.
* @public
*/
ensureStoreIsUsable(_config = {} as any): void {
return
}
}

View file

@ -1,4 +1,5 @@
import { getOwnProperty, objectMapValues } from '@tldraw/utils'
import { Signal } from 'signia'
import { BaseRecord } from './BaseRecord'
import { RecordType } from './RecordType'
import { Store, StoreSnapshot } from './Store'
@ -48,6 +49,8 @@ export type StoreSchemaOptions<R extends BaseRecord, P> = {
}) => R
/** @internal */
ensureStoreIsUsable?: (store: Store<R, P>) => void
/** @internal */
derivePresenceState?: (store: Store<R, P>) => Signal<R | null>
}
/** @public */
@ -241,6 +244,11 @@ export class StoreSchema<R extends BaseRecord, P = unknown> {
this.options.ensureStoreIsUsable?.(store)
}
/** @internal */
derivePresenceState(store: Store<R, P>): Signal<R | null> | undefined {
return this.options.derivePresenceState?.(store)
}
serialize(): SerializedSchema {
return {
schemaVersion: 1,

View file

@ -1,7 +1,7 @@
import { SerializedSchema } from './StoreSchema'
/** @public */
export const compareSchemas = (a: SerializedSchema, b: SerializedSchema): number => {
export const compareSchemas = (a: SerializedSchema, b: SerializedSchema): 0 | 1 | -1 => {
if (a.schemaVersion > b.schemaVersion) {
return 1
}

View file

@ -10,7 +10,10 @@ interface Book extends BaseRecord<'book'> {
numPages: number
}
const Book = createRecordType<Book>('book', { validator: { validate: (book) => book as Book } })
const Book = createRecordType<Book>('book', {
validator: { validate: (book) => book as Book },
scope: 'document',
})
interface Author extends BaseRecord<'author'> {
name: string
@ -19,6 +22,7 @@ interface Author extends BaseRecord<'author'> {
const Author = createRecordType<Author>('author', {
validator: { validate: (author) => author as Author },
scope: 'document',
}).withDefaultProperties(() => ({
isPseudonym: false,
}))

View file

@ -21,6 +21,7 @@ const Author = createRecordType<Author>('author', {
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({ age: 23 }))
interface Book extends BaseRecord<'book'> {
@ -38,6 +39,7 @@ const Book = createRecordType<Book>('book', {
return book
},
},
scope: 'document',
})
const bookComparator = (a: Book, b: Book) => a.id.localeCompare(b.id)

View file

@ -19,6 +19,7 @@ const Author = createRecordType<Author>('author', {
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({ age: 23 }))
interface Book extends BaseRecord<'book'> {
@ -36,6 +37,7 @@ const Book = createRecordType<Book>('book', {
return book
},
},
scope: 'document',
})
const authors = {
tolkein: Author.create({ name: 'J.R.R. Tolkein' }),

View file

@ -29,6 +29,7 @@ const User = createRecordType<User>('user', {
return record as User
},
},
scope: 'document',
})
const ShapeVersion = {
@ -90,6 +91,7 @@ const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
return record as Shape<RectangleProps | OvalProps>
},
},
scope: 'document',
})
// this interface only exists to be removed
@ -107,6 +109,7 @@ const Org = createRecordType<Org>('org', {
return record as Org
},
},
scope: 'document',
})
export const testSchemaV0 = StoreSchema.create(

View file

@ -62,6 +62,7 @@ const User = createRecordType<User>('user', {
return record as User
},
},
scope: 'document',
}).withDefaultProperties(() => ({
/* STEP 6: Add any new default values for properties here */
name: 'New User',
@ -192,6 +193,7 @@ const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
return record as Shape<RectangleProps | OvalProps>
},
},
scope: 'document',
}).withDefaultProperties(() => ({
x: 0,
y: 0,

View file

@ -21,6 +21,7 @@ const Book = createRecordType<Book>('book', {
return book
},
},
scope: 'document',
})
interface Author extends BaseRecord<'author'> {
@ -39,6 +40,7 @@ const Author = createRecordType<Author>('author', {
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({
isPseudonym: false,
}))

View file

@ -1,3 +1,3 @@
require('fake-indexeddb/auto')
global.ResizeObserver = require('resize-observer-polyfill')
global.crypto = new (require('@peculiar/webcrypto').Crypto)()
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()

View file

@ -37,6 +37,13 @@ export type ErrorResult<E> = {
// @internal (undocumented)
export function exhaustiveSwitchError(value: never, property?: string): never;
// @internal
export function filterEntries<Key extends string, Value>(object: {
[K in Key]: Value;
}, predicate: (key: Key, value: Value) => boolean): {
[K in Key]: Value;
};
// @internal (undocumented)
export function getErrorAnnotations(error: Error): ErrorAnnotations;

View file

@ -16,6 +16,7 @@ export { getFirstFromIterable } from './lib/iterable'
export { lerp, modulate, rng } from './lib/number'
export {
deepCopy,
filterEntries,
getOwnProperty,
hasOwnProperty,
objectMapEntries,

View file

@ -87,3 +87,24 @@ export function objectMapEntries<Key extends string, Value>(object: {
}): Array<[Key, Value]> {
return Object.entries(object) as [Key, Value][]
}
/**
* Filters an object using a predicate function.
* @returns a new object with only the entries that pass the predicate
* @internal
*/
export function filterEntries<Key extends string, Value>(
object: { [K in Key]: Value },
predicate: (key: Key, value: Value) => boolean
): { [K in Key]: Value } {
const result: { [K in Key]?: Value } = {}
let didChange = false
for (const [key, value] of objectMapEntries(object)) {
if (predicate(key, value)) {
result[key] = value
} else {
didChange = true
}
}
return didChange ? (result as { [K in Key]: Value }) : object
}

View file

@ -4049,90 +4049,90 @@ __metadata:
languageName: node
linkType: hard
"@swc/core-darwin-arm64@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-darwin-arm64@npm:1.3.52"
"@swc/core-darwin-arm64@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-darwin-arm64@npm:1.3.55"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@swc/core-darwin-x64@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-darwin-x64@npm:1.3.52"
"@swc/core-darwin-x64@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-darwin-x64@npm:1.3.55"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@swc/core-linux-arm-gnueabihf@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.52"
"@swc/core-linux-arm-gnueabihf@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.55"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@swc/core-linux-arm64-gnu@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.52"
"@swc/core-linux-arm64-gnu@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.55"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-arm64-musl@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-linux-arm64-musl@npm:1.3.52"
"@swc/core-linux-arm64-musl@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-linux-arm64-musl@npm:1.3.55"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@swc/core-linux-x64-gnu@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-linux-x64-gnu@npm:1.3.52"
"@swc/core-linux-x64-gnu@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-linux-x64-gnu@npm:1.3.55"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@swc/core-linux-x64-musl@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-linux-x64-musl@npm:1.3.52"
"@swc/core-linux-x64-musl@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-linux-x64-musl@npm:1.3.55"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@swc/core-win32-arm64-msvc@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.52"
"@swc/core-win32-arm64-msvc@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.55"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@swc/core-win32-ia32-msvc@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.52"
"@swc/core-win32-ia32-msvc@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.55"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@swc/core-win32-x64-msvc@npm:1.3.52":
version: 1.3.52
resolution: "@swc/core-win32-x64-msvc@npm:1.3.52"
"@swc/core-win32-x64-msvc@npm:1.3.55":
version: 1.3.55
resolution: "@swc/core-win32-x64-msvc@npm:1.3.55"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@swc/core@npm:^1.2.204, @swc/core@npm:^1.3.41":
version: 1.3.52
resolution: "@swc/core@npm:1.3.52"
"@swc/core@npm:^1.3.55":
version: 1.3.55
resolution: "@swc/core@npm:1.3.55"
dependencies:
"@swc/core-darwin-arm64": 1.3.52
"@swc/core-darwin-x64": 1.3.52
"@swc/core-linux-arm-gnueabihf": 1.3.52
"@swc/core-linux-arm64-gnu": 1.3.52
"@swc/core-linux-arm64-musl": 1.3.52
"@swc/core-linux-x64-gnu": 1.3.52
"@swc/core-linux-x64-musl": 1.3.52
"@swc/core-win32-arm64-msvc": 1.3.52
"@swc/core-win32-ia32-msvc": 1.3.52
"@swc/core-win32-x64-msvc": 1.3.52
"@swc/core-darwin-arm64": 1.3.55
"@swc/core-darwin-x64": 1.3.55
"@swc/core-linux-arm-gnueabihf": 1.3.55
"@swc/core-linux-arm64-gnu": 1.3.55
"@swc/core-linux-arm64-musl": 1.3.55
"@swc/core-linux-x64-gnu": 1.3.55
"@swc/core-linux-x64-musl": 1.3.55
"@swc/core-win32-arm64-msvc": 1.3.55
"@swc/core-win32-ia32-msvc": 1.3.55
"@swc/core-win32-x64-msvc": 1.3.55
peerDependencies:
"@swc/helpers": ^0.5.0
dependenciesMeta:
@ -4159,7 +4159,7 @@ __metadata:
peerDependenciesMeta:
"@swc/helpers":
optional: true
checksum: ae92657347b223ddbcc47d995966517356bbd600f775fcd74805c95eb8b10e80d0db1def315c710675fa40cae3c89cf26c413bccf1ea884066ba435f65425864
checksum: e8ae32e21e78761597b802bd76bb5f0d819441454c4cc5624c077dfa8cf84760eb589e4a1eb6fdc1b1ec65a4b03f7ab42413952b38234f5c13b8b2afb4d453f4
languageName: node
linkType: hard
@ -4172,7 +4172,7 @@ __metadata:
languageName: node
linkType: hard
"@swc/jest@npm:^0.2.21, @swc/jest@npm:^0.2.24":
"@swc/jest@npm:^0.2.26":
version: 0.2.26
resolution: "@swc/jest@npm:0.2.26"
dependencies:
@ -4274,8 +4274,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "@tldraw/assets@workspace:packages/assets"
dependencies:
"@swc/core": ^1.2.204
"@swc/jest": ^0.2.21
"@tldraw/utils": "workspace:*"
lazyrepo: 0.0.0-alpha.22
ts-node-dev: ^1.1.8
@ -4382,8 +4380,8 @@ __metadata:
dependencies:
"@microsoft/api-extractor": ^7.34.1
"@next/eslint-plugin-next": ^13.3.0
"@swc/core": ^1.3.41
"@swc/jest": ^0.2.24
"@swc/core": ^1.3.55
"@swc/jest": ^0.2.26
"@types/glob": ^8.1.0
"@types/jest": ^28.1.2
"@types/node": 18.7.3

View file

@ -55,9 +55,8 @@ async function main() {
console.log('Checking with tsconfig:', tsconfig)
writeFileSync(`${tempDir}/tsconfig.json`, JSON.stringify(tsconfig, null, '\t'), 'utf8')
writeFileSync(`${tempDir}/package.json`, JSON.stringify({ dependencies: {} }, null, '\t'), 'utf8')
writeFileSync(`${tempDir}/.yarnrc.yml`, 'nodeLinker: node-modules\n', 'utf8')
await exec('yarn', ['add', ...packagesOurTypesCanDependOn], { pwd: tempDir })
await exec('npm', ['install', ...packagesOurTypesCanDependOn], { pwd: tempDir })
await exec(resolve('./node_modules/.bin/tsc'), [], { pwd: tempDir })
await exec('rm', ['-rf', tempDir])