@tldraw/tlschema 4.5.2 → 4.6.0-canary.4ec045c286e1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,11 @@ import { arrowBindingMigrations, arrowBindingProps } from './bindings/TLArrowBin
8
8
  import { AssetRecordType, assetMigrations } from './records/TLAsset'
9
9
  import { TLBinding, TLDefaultBinding, createBindingRecordType } from './records/TLBinding'
10
10
  import { CameraRecordType, cameraMigrations } from './records/TLCamera'
11
+ import {
12
+ CustomRecordInfo,
13
+ createCustomRecordType,
14
+ processCustomRecordMigrations,
15
+ } from './records/TLCustomRecord'
11
16
  import { DocumentRecordType, documentMigrations } from './records/TLDocument'
12
17
  import { createInstanceRecordType, instanceMigrations } from './records/TLInstance'
13
18
  import { PageRecordType, pageMigrations } from './records/TLPage'
@@ -193,12 +198,14 @@ export const defaultBindingSchemas = {
193
198
  * validation, and migration sequences for all record types in a tldraw application.
194
199
  *
195
200
  * The schema includes all core record types (pages, cameras, instances, etc.) plus the
196
- * shape and binding types you specify. Style properties are automatically collected from
197
- * all shapes to ensure consistency across the application.
201
+ * shape, binding, and custom record types you specify. Style properties are automatically
202
+ * collected from all shapes to ensure consistency across the application.
198
203
  *
199
204
  * @param options - Configuration options for the schema
200
205
  * - shapes - Shape schema configurations. Defaults to defaultShapeSchemas if not provided
201
206
  * - bindings - Binding schema configurations. Defaults to defaultBindingSchemas if not provided
207
+ * - records - Custom record type configurations. These are additional record types beyond
208
+ * the built-in shapes, bindings, assets, etc.
202
209
  * - migrations - Additional migration sequences to include in the schema
203
210
  * @returns A complete TLSchema ready for use with Store creation
204
211
  *
@@ -222,13 +229,19 @@ export const defaultBindingSchemas = {
222
229
  * },
223
230
  * })
224
231
  *
225
- * // Create schema with only specific shapes
226
- * const minimalSchema = createTLSchema({
227
- * shapes: {
228
- * geo: defaultShapeSchemas.geo,
229
- * text: defaultShapeSchemas.text,
232
+ * // Create schema with custom record types
233
+ * const schemaWithCustomRecords = createTLSchema({
234
+ * records: {
235
+ * comment: {
236
+ * scope: 'document',
237
+ * validator: T.object({
238
+ * id: T.string,
239
+ * typeName: T.literal('comment'),
240
+ * text: T.string,
241
+ * shapeId: T.string,
242
+ * }),
243
+ * },
230
244
  * },
231
- * bindings: defaultBindingSchemas,
232
245
  * })
233
246
  *
234
247
  * // Use the schema with a store
@@ -243,10 +256,12 @@ export const defaultBindingSchemas = {
243
256
  export function createTLSchema({
244
257
  shapes = defaultShapeSchemas,
245
258
  bindings = defaultBindingSchemas,
259
+ records = {},
246
260
  migrations,
247
261
  }: {
248
262
  shapes?: Record<string, SchemaPropsInfo>
249
263
  bindings?: Record<string, SchemaPropsInfo>
264
+ records?: Record<string, CustomRecordInfo>
250
265
  migrations?: readonly MigrationSequence[]
251
266
  } = {}): TLSchema {
252
267
  const stylesById = new Map<string, StyleProp<unknown>>()
@@ -263,6 +278,30 @@ export function createTLSchema({
263
278
  const BindingRecordType = createBindingRecordType(bindings)
264
279
  const InstanceRecordType = createInstanceRecordType(stylesById)
265
280
 
281
+ // Create RecordTypes for custom records
282
+ const builtInTypeNames = new Set([
283
+ 'asset',
284
+ 'binding',
285
+ 'camera',
286
+ 'document',
287
+ 'instance',
288
+ 'instance_page_state',
289
+ 'page',
290
+ 'instance_presence',
291
+ 'pointer',
292
+ 'shape',
293
+ 'store',
294
+ ])
295
+ const customRecordTypes: Record<string, { createId: any }> = {}
296
+ for (const [typeName, config] of Object.entries(records)) {
297
+ if (builtInTypeNames.has(typeName)) {
298
+ throw new Error(
299
+ `Custom record type name '${typeName}' conflicts with tldraw's built-in record type of that name. Choose a different name instead.`
300
+ )
301
+ }
302
+ customRecordTypes[typeName] = createCustomRecordType(typeName, config)
303
+ }
304
+
266
305
  return StoreSchema.create(
267
306
  {
268
307
  asset: AssetRecordType,
@@ -275,6 +314,7 @@ export function createTLSchema({
275
314
  instance_presence: InstancePresenceRecordType,
276
315
  pointer: PointerRecordType,
277
316
  shape: ShapeRecordType,
317
+ ...customRecordTypes,
278
318
  },
279
319
  {
280
320
  migrations: [
@@ -295,6 +335,7 @@ export function createTLSchema({
295
335
 
296
336
  ...processPropsMigrations<TLShape>('shape', shapes),
297
337
  ...processPropsMigrations<TLBinding>('binding', bindings),
338
+ ...processCustomRecordMigrations(records),
298
339
 
299
340
  ...(migrations ?? []),
300
341
  ],
package/src/index.ts CHANGED
@@ -104,6 +104,14 @@ export {
104
104
  type TLUnknownBinding,
105
105
  } from './records/TLBinding'
106
106
  export { CameraRecordType, type TLCamera, type TLCameraId } from './records/TLCamera'
107
+ export {
108
+ createCustomRecordId,
109
+ createCustomRecordMigrationIds,
110
+ createCustomRecordMigrationSequence,
111
+ isCustomRecord,
112
+ isCustomRecordId,
113
+ type CustomRecordInfo,
114
+ } from './records/TLCustomRecord'
107
115
  export {
108
116
  DocumentRecordType,
109
117
  isDocument,
@@ -139,7 +147,13 @@ export {
139
147
  type TLInstancePresence,
140
148
  type TLInstancePresenceID,
141
149
  } from './records/TLPresence'
142
- export { type TLRecord } from './records/TLRecord'
150
+ export {
151
+ type TLCustomRecord,
152
+ type TLDefaultRecord,
153
+ type TLGlobalRecordPropsMap,
154
+ type TLIndexedRecords,
155
+ type TLRecord,
156
+ } from './records/TLRecord'
143
157
  export {
144
158
  createShapeId,
145
159
  createShapePropsMigrationIds,
@@ -0,0 +1,297 @@
1
+ import {
2
+ MigrationSequence,
3
+ RecordId,
4
+ RecordScope,
5
+ UnknownRecord,
6
+ createMigrationSequence,
7
+ createRecordType,
8
+ } from '@tldraw/store'
9
+ import { assert, mapObjectMapValues, uniqueId } from '@tldraw/utils'
10
+ import { T } from '@tldraw/validate'
11
+ import { TLPropsMigrations } from '../recordsWithProps'
12
+
13
+ /**
14
+ * Configuration for a custom record type in the schema.
15
+ *
16
+ * Custom record types allow you to add entirely new data types to the tldraw store
17
+ * that don't fit into the existing shape, binding, or asset categories. This is useful
18
+ * for storing domain-specific data like comments, annotations, or application state
19
+ * that needs to participate in persistence and synchronization.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const commentRecordConfig: CustomRecordInfo = {
24
+ * scope: 'document',
25
+ * validator: T.object({
26
+ * id: T.string,
27
+ * typeName: T.literal('comment'),
28
+ * text: T.string,
29
+ * shapeId: T.string,
30
+ * authorId: T.string,
31
+ * createdAt: T.number,
32
+ * }),
33
+ * migrations: createRecordMigrationSequence({
34
+ * sequenceId: 'com.myapp.comment',
35
+ * recordType: 'comment',
36
+ * sequence: [],
37
+ * }),
38
+ * }
39
+ * ```
40
+ *
41
+ * @public
42
+ */
43
+ export interface CustomRecordInfo {
44
+ /**
45
+ * The scope determines how records of this type are persisted and synchronized:
46
+ * - **document**: Persisted and synced across all clients
47
+ * - **session**: Local to current session, not synced
48
+ * - **presence**: Ephemeral presence data, may be synced but not persisted
49
+ */
50
+ scope: RecordScope
51
+
52
+ /**
53
+ * Validator for the complete record structure.
54
+ *
55
+ * Should validate the entire record including `id` and `typeName` fields.
56
+ * Use validators like T.object, T.string, etc.
57
+ */
58
+ validator: T.Validatable<any>
59
+
60
+ /**
61
+ * Optional migration sequence for handling schema evolution over time.
62
+ *
63
+ * Can be a full MigrationSequence or a simplified TLPropsMigrations format.
64
+ * If not provided, an empty migration sequence will be created automatically.
65
+ */
66
+ migrations?: MigrationSequence | TLPropsMigrations
67
+
68
+ /**
69
+ * Optional factory function that returns default property values for new records.
70
+ *
71
+ * Called when creating new records to provide initial values for any properties
72
+ * not explicitly provided during creation.
73
+ */
74
+ // eslint-disable-next-line @typescript-eslint/method-signature-style
75
+ createDefaultProperties?: () => Record<string, unknown>
76
+ }
77
+
78
+ /**
79
+ * Creates a RecordType for a custom record based on its configuration.
80
+ *
81
+ * @param typeName - The unique type name for this record type
82
+ * @param config - Configuration for the custom record type
83
+ * @returns A RecordType instance that can be used to create and manage records
84
+ *
85
+ * @internal
86
+ */
87
+ export function createCustomRecordType(typeName: string, config: CustomRecordInfo) {
88
+ return createRecordType<UnknownRecord>(typeName, {
89
+ scope: config.scope,
90
+ validator: config.validator,
91
+ }).withDefaultProperties(config.createDefaultProperties ?? (() => ({})))
92
+ }
93
+
94
+ /**
95
+ * Processes migrations for custom record types.
96
+ *
97
+ * Converts the migration configuration from CustomRecordInfo into proper
98
+ * MigrationSequence objects that can be used by the store system.
99
+ *
100
+ * @param records - Record of type names to their configuration
101
+ * @returns Array of migration sequences for the custom record types
102
+ *
103
+ * @internal
104
+ */
105
+ export function processCustomRecordMigrations(
106
+ records: Record<string, CustomRecordInfo>
107
+ ): MigrationSequence[] {
108
+ const result: MigrationSequence[] = []
109
+
110
+ for (const [typeName, config] of Object.entries(records)) {
111
+ const sequenceId = `com.tldraw.${typeName}`
112
+ const { migrations } = config
113
+
114
+ if (!migrations) {
115
+ // Provide empty migration sequence to allow for future migrations
116
+ result.push(
117
+ createMigrationSequence({
118
+ sequenceId,
119
+ retroactive: true,
120
+ sequence: [],
121
+ })
122
+ )
123
+ } else if ('sequenceId' in migrations) {
124
+ // Full MigrationSequence provided
125
+ assert(
126
+ sequenceId === migrations.sequenceId,
127
+ `sequenceId mismatch for ${typeName} custom record migrations. Expected '${sequenceId}', got '${migrations.sequenceId}'`
128
+ )
129
+ result.push(migrations)
130
+ } else if ('sequence' in migrations) {
131
+ // TLPropsMigrations format - convert to full MigrationSequence
132
+ result.push(
133
+ createMigrationSequence({
134
+ sequenceId,
135
+ retroactive: true,
136
+ sequence: migrations.sequence.map((m) => {
137
+ if (!('id' in m)) return m
138
+ return {
139
+ id: m.id,
140
+ dependsOn: m.dependsOn,
141
+ scope: 'record' as const,
142
+ filter: (r: UnknownRecord) => r.typeName === typeName,
143
+ up: (record: any) => {
144
+ const result = m.up(record)
145
+ if (result) return result
146
+ },
147
+ down:
148
+ typeof m.down === 'function'
149
+ ? (record: any) => {
150
+ const result = (m.down as (r: any) => any)(record)
151
+ if (result) return result
152
+ }
153
+ : undefined,
154
+ }
155
+ }),
156
+ })
157
+ )
158
+ }
159
+ }
160
+
161
+ return result
162
+ }
163
+
164
+ /**
165
+ * Creates properly formatted migration IDs for custom record migrations.
166
+ *
167
+ * Generates standardized migration IDs following the convention:
168
+ * `com.tldraw.{recordType}/{version}`
169
+ *
170
+ * @param recordType - The type name of the custom record
171
+ * @param ids - Record mapping migration names to version numbers
172
+ * @returns Record with the same keys but formatted migration ID values
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * const commentVersions = createCustomRecordMigrationIds('comment', {
177
+ * AddAuthorId: 1,
178
+ * AddCreatedAt: 2,
179
+ * RefactorReactions: 3
180
+ * })
181
+ * // Result: {
182
+ * // AddAuthorId: 'com.tldraw.comment/1',
183
+ * // AddCreatedAt: 'com.tldraw.comment/2',
184
+ * // RefactorReactions: 'com.tldraw.comment/3'
185
+ * // }
186
+ * ```
187
+ *
188
+ * @public
189
+ */
190
+ export function createCustomRecordMigrationIds<
191
+ const S extends string,
192
+ const T extends Record<string, number>,
193
+ >(recordType: S, ids: T): { [k in keyof T]: `com.tldraw.${S}/${T[k]}` } {
194
+ return mapObjectMapValues(ids, (_k, v) => `com.tldraw.${recordType}/${v}`) as any
195
+ }
196
+
197
+ /**
198
+ * Creates a migration sequence for custom record types.
199
+ *
200
+ * This is a pass-through function that maintains the same structure as the input.
201
+ * It's used for consistency and to provide a clear API for defining custom record migrations.
202
+ *
203
+ * @param migrations - The migration sequence to create
204
+ * @returns The same migration sequence (pass-through)
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const commentMigrations = createCustomRecordMigrationSequence({
209
+ * sequence: [
210
+ * {
211
+ * id: 'com.myapp.comment/1',
212
+ * up: (record) => ({ ...record, authorId: record.authorId ?? 'unknown' }),
213
+ * down: ({ authorId, ...record }) => record
214
+ * }
215
+ * ]
216
+ * })
217
+ * ```
218
+ *
219
+ * @public
220
+ */
221
+ export function createCustomRecordMigrationSequence(
222
+ migrations: TLPropsMigrations
223
+ ): TLPropsMigrations {
224
+ return migrations
225
+ }
226
+
227
+ /**
228
+ * Creates a unique ID for a custom record type.
229
+ *
230
+ * @param typeName - The type name of the custom record
231
+ * @param id - Optional custom ID suffix. If not provided, a unique ID will be generated
232
+ * @returns A properly formatted record ID
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * // Create with auto-generated ID
237
+ * const commentId = createCustomRecordId('comment') // 'comment:abc123'
238
+ *
239
+ * // Create with custom ID
240
+ * const customId = createCustomRecordId('comment', 'my-comment') // 'comment:my-comment'
241
+ * ```
242
+ *
243
+ * @public
244
+ */
245
+ export function createCustomRecordId<T extends string>(
246
+ typeName: T,
247
+ id?: string
248
+ ): RecordId<UnknownRecord> & `${T}:${string}` {
249
+ return `${typeName}:${id ?? uniqueId()}` as RecordId<UnknownRecord> & `${T}:${string}`
250
+ }
251
+
252
+ /**
253
+ * Type guard to check if a string is a valid ID for a specific custom record type.
254
+ *
255
+ * @param typeName - The type name to check against
256
+ * @param id - The string to check
257
+ * @returns True if the string is a valid ID for the specified record type
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * const id = 'comment:abc123'
262
+ * if (isCustomRecordId('comment', id)) {
263
+ * // id is now typed as a comment record ID
264
+ * const comment = store.get(id)
265
+ * }
266
+ * ```
267
+ *
268
+ * @public
269
+ */
270
+ export function isCustomRecordId(typeName: string, id?: string): boolean {
271
+ if (!id) return false
272
+ return id.startsWith(`${typeName}:`)
273
+ }
274
+
275
+ /**
276
+ * Type guard to check if a record is of a specific custom type.
277
+ *
278
+ * @param typeName - The type name to check against
279
+ * @param record - The record to check
280
+ * @returns True if the record is of the specified type
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * function handleRecord(record: TLRecord) {
285
+ * if (isCustomRecord('comment', record)) {
286
+ * // Handle comment record
287
+ * console.log(`Comment: ${record.text}`)
288
+ * }
289
+ * }
290
+ * ```
291
+ *
292
+ * @public
293
+ */
294
+ export function isCustomRecord(typeName: string, record?: UnknownRecord): boolean {
295
+ if (!record) return false
296
+ return record.typeName === typeName
297
+ }
@@ -9,10 +9,89 @@ import { TLPointer } from './TLPointer'
9
9
  import { TLInstancePresence } from './TLPresence'
10
10
  import { TLShape } from './TLShape'
11
11
 
12
+ /**
13
+ * Interface for extending tldraw with custom record types via TypeScript module augmentation.
14
+ *
15
+ * Custom record types allow you to add entirely new data types to the tldraw store that
16
+ * don't fit into the existing shape, binding, or asset categories. Each key in this
17
+ * interface becomes a new record type name, and the value should be your full record type.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { BaseRecord, RecordId } from '@tldraw/store'
22
+ *
23
+ * // Define your custom record type
24
+ * interface TLComment extends BaseRecord<'comment', RecordId<TLComment>> {
25
+ * text: string
26
+ * shapeId: TLShapeId
27
+ * authorId: string
28
+ * createdAt: number
29
+ * }
30
+ *
31
+ * // Augment the global record props map
32
+ * declare module '@tldraw/tlschema' {
33
+ * interface TLGlobalRecordPropsMap {
34
+ * comment: TLComment
35
+ * }
36
+ * }
37
+ *
38
+ * // Now TLRecord includes your custom comment type
39
+ * // and you can use it with createTLSchema()
40
+ * ```
41
+ *
42
+ * @public
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
45
+ export interface TLGlobalRecordPropsMap {}
46
+
47
+ /**
48
+ * Union type of all built-in tldraw record types.
49
+ *
50
+ * This includes persistent records (documents, pages, shapes, assets, bindings)
51
+ * and session/presence records (cameras, instances, pointers, page states).
52
+ *
53
+ * @public
54
+ */
55
+ export type TLDefaultRecord =
56
+ | TLAsset
57
+ | TLBinding
58
+ | TLCamera
59
+ | TLDocument
60
+ | TLInstance
61
+ | TLInstancePageState
62
+ | TLPage
63
+ | TLShape
64
+ | TLInstancePresence
65
+ | TLPointer
66
+
67
+ /**
68
+ * Index type that maps custom record type names to their record types.
69
+ *
70
+ * Similar to TLIndexedShapes and TLIndexedBindings, this type creates a mapping
71
+ * from type name keys to their corresponding record types, filtering out any
72
+ * disabled types (those set to null or undefined in TLGlobalRecordPropsMap).
73
+ *
74
+ * @public
75
+ */
76
+ // prettier-ignore
77
+ export type TLIndexedRecords = {
78
+ [K in keyof TLGlobalRecordPropsMap as TLGlobalRecordPropsMap[K] extends null | undefined
79
+ ? never
80
+ : K]: TLGlobalRecordPropsMap[K]
81
+ }
82
+
83
+ /**
84
+ * Union type representing a custom record from the TLGlobalRecordPropsMap.
85
+ *
86
+ * @public
87
+ */
88
+ export type TLCustomRecord = TLIndexedRecords[keyof TLIndexedRecords]
89
+
12
90
  /**
13
91
  * Union type representing all possible record types in a tldraw store.
14
92
  * This includes both persistent records (documents, pages, shapes, assets, bindings)
15
- * and session/presence records (cameras, instances, pointers, page states).
93
+ * and session/presence records (cameras, instances, pointers, page states),
94
+ * as well as any custom record types added via TLGlobalRecordPropsMap augmentation.
16
95
  *
17
96
  * Records are organized by scope:
18
97
  * - **document**: Persisted across sessions (shapes, pages, assets, bindings, documents)
@@ -52,14 +131,4 @@ import { TLShape } from './TLShape'
52
131
  *
53
132
  * @public
54
133
  */
55
- export type TLRecord =
56
- | TLAsset
57
- | TLBinding
58
- | TLCamera
59
- | TLDocument
60
- | TLInstance
61
- | TLInstancePageState
62
- | TLPage
63
- | TLShape
64
- | TLInstancePresence
65
- | TLPointer
134
+ export type TLRecord = TLDefaultRecord | TLCustomRecord