@tldraw/tlschema 5.3.0-next.fc37bd96825b → 5.3.1

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.
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createTLSchema } from '../createTLSchema'
3
+ import { toRichText } from '../misc/TLRichText'
4
+ import {
5
+ commentReactionRecordConfig,
6
+ commentRecordConfig,
7
+ commentSchemaRecords,
8
+ commentThreadRecordConfig,
9
+ createComment,
10
+ createCommentId,
11
+ createCommentReaction,
12
+ createCommentReactionId,
13
+ createCommentThread,
14
+ createCommentThreadId,
15
+ isCommentId,
16
+ isCommentReactionId,
17
+ isCommentThreadId,
18
+ TLCommentAnchor,
19
+ } from './TLComment'
20
+ import { TLPageId } from './TLPage'
21
+ import { TLShapeId } from './TLShape'
22
+
23
+ const pageId = 'page:page1' as TLPageId
24
+ const shapeId = 'shape:box1' as TLShapeId
25
+
26
+ const anchors: TLCommentAnchor[] = [
27
+ { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
28
+ { type: 'point', x: 100, y: 200 },
29
+ { type: 'region', x: 0, y: 0, w: 300, h: 150 },
30
+ { type: 'page' },
31
+ ]
32
+
33
+ describe('TLCommentThread', () => {
34
+ it.each(anchors)('creates a valid thread anchored to %j', (anchor) => {
35
+ const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 })
36
+ expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread)
37
+ expect(thread).toMatchObject({
38
+ typeName: 'comment-thread',
39
+ pageId,
40
+ anchor,
41
+ createdAt: 1000,
42
+ resolved: null,
43
+ meta: {},
44
+ })
45
+ })
46
+
47
+ it('validates resolution state', () => {
48
+ const thread = createCommentThread({
49
+ pageId,
50
+ anchor: { type: 'page' },
51
+ createdBy: 'user1',
52
+ now: 1000,
53
+ })
54
+ const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } }
55
+ expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved)
56
+ expect(() =>
57
+ commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } })
58
+ ).toThrow()
59
+ })
60
+
61
+ it('rejects unknown anchor kinds and malformed anchors', () => {
62
+ const thread = createCommentThread({
63
+ pageId,
64
+ anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
65
+ createdBy: 'user1',
66
+ now: 1000,
67
+ })
68
+ expect(() =>
69
+ commentThreadRecordConfig.validator.validate({
70
+ ...thread,
71
+ anchor: { type: 'volume', level: 11 },
72
+ })
73
+ ).toThrow()
74
+ expect(() =>
75
+ commentThreadRecordConfig.validator.validate({
76
+ ...thread,
77
+ // shape anchors must reference a shape id
78
+ anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false },
79
+ })
80
+ ).toThrow()
81
+ })
82
+ })
83
+
84
+ describe('TLComment', () => {
85
+ const threadId = createCommentThreadId('thread1')
86
+
87
+ it('creates a valid comment with a rich text body', () => {
88
+ const comment = createComment({
89
+ threadId,
90
+ pageId,
91
+ authorId: 'user1',
92
+ body: toRichText('hello'),
93
+ now: 1000,
94
+ })
95
+ expect(commentRecordConfig.validator.validate(comment)).toEqual(comment)
96
+ expect(comment).toMatchObject({
97
+ typeName: 'comment',
98
+ threadId,
99
+ pageId,
100
+ createdAt: 1000,
101
+ editedAt: null,
102
+ isDeleted: false,
103
+ })
104
+ })
105
+
106
+ it('validates soft-deletion state', () => {
107
+ const comment = createComment({
108
+ threadId,
109
+ pageId,
110
+ authorId: 'user1',
111
+ body: toRichText('hello'),
112
+ now: 1000,
113
+ })
114
+ const deleted = { ...comment, isDeleted: true }
115
+ expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted)
116
+ expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow()
117
+ })
118
+
119
+ it('accepts an editedAt timestamp after edits', () => {
120
+ const comment = createComment({
121
+ threadId,
122
+ pageId,
123
+ authorId: 'user1',
124
+ body: toRichText('hello'),
125
+ now: 1000,
126
+ })
127
+ const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') }
128
+ expect(commentRecordConfig.validator.validate(edited)).toEqual(edited)
129
+ })
130
+
131
+ it('rejects a threadId that is not a comment-thread id', () => {
132
+ const comment = createComment({
133
+ threadId,
134
+ pageId,
135
+ authorId: 'user1',
136
+ body: toRichText('hello'),
137
+ now: 1000,
138
+ })
139
+ expect(() =>
140
+ commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' })
141
+ ).toThrow()
142
+ })
143
+ })
144
+
145
+ describe('schema registration', () => {
146
+ it('registers both record types via commentSchemaRecords', () => {
147
+ const schema = createTLSchema({ records: commentSchemaRecords })
148
+ // custom record types aren't part of the default TLRecord union, so index untyped
149
+ const types = schema.types as Record<string, { scope: string } | undefined>
150
+ expect(types['comment-thread']?.scope).toBe('document')
151
+ expect(types['comment']?.scope).toBe('document')
152
+ })
153
+ })
154
+
155
+ describe('isCommentThreadId / isCommentId', () => {
156
+ it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => {
157
+ expect(isCommentThreadId(createCommentThreadId())).toBe(true)
158
+ expect(isCommentThreadId(createCommentId())).toBe(false)
159
+ expect(isCommentThreadId('shape:box1')).toBe(false)
160
+ expect(isCommentThreadId('not-an-id')).toBe(false)
161
+ })
162
+
163
+ it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => {
164
+ expect(isCommentId(createCommentId())).toBe(true)
165
+ expect(isCommentId(createCommentThreadId())).toBe(false)
166
+ expect(isCommentId('shape:box1')).toBe(false)
167
+ expect(isCommentId('not-an-id')).toBe(false)
168
+ })
169
+ })
170
+
171
+ describe('comment-thread migrations', () => {
172
+ const migration = (commentThreadRecordConfig.migrations as any).sequence.find(
173
+ (m: any) => m.id === 'com.tldraw.comment-thread/2'
174
+ ) as { up(r: any): any; down(r: any): any }
175
+
176
+ it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => {
177
+ expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({
178
+ anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
179
+ })
180
+ })
181
+
182
+ it('leaves shape anchors that already have x/y unchanged', () => {
183
+ const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true }
184
+ expect(migration.up({ anchor })).toEqual({ anchor })
185
+ })
186
+
187
+ it('leaves non-shape anchors untouched', () => {
188
+ const anchor = { type: 'point', x: 5, y: 6 }
189
+ expect(migration.up({ anchor })).toEqual({ anchor })
190
+ })
191
+
192
+ it('down-migrates by dropping x/y', () => {
193
+ expect(
194
+ migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } })
195
+ ).toEqual({
196
+ anchor: { type: 'shape', shapeId },
197
+ })
198
+ })
199
+ })
200
+
201
+ describe('comment-reaction record', () => {
202
+ it('createCommentReaction derives its id from the comment, user, and emoji', () => {
203
+ const commentId = createCommentId('c1')
204
+ const make = (emoji: string) =>
205
+ createCommentReaction({
206
+ commentId,
207
+ threadId: createCommentThreadId('t1'),
208
+ pageId,
209
+ userId: 'user1',
210
+ emoji,
211
+ })
212
+ // same emoji → same record (re-picking toggles it); different emoji → its own record (multi)
213
+ expect(make('👍').id).toBe(make('👍').id)
214
+ expect(make('👍').id).not.toBe(make('🎉').id)
215
+ expect(isCommentReactionId(make('👍').id)).toBe(true)
216
+ })
217
+
218
+ it('validates a well-formed reaction record', () => {
219
+ const reaction = createCommentReaction({
220
+ commentId: createCommentId('c1'),
221
+ threadId: createCommentThreadId('t1'),
222
+ pageId,
223
+ userId: 'user1',
224
+ emoji: '👍',
225
+ })
226
+ expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction)
227
+ })
228
+
229
+ it('rejects an empty or oversized emoji token', () => {
230
+ const reaction = createCommentReaction({
231
+ commentId: createCommentId('c1'),
232
+ threadId: createCommentThreadId('t1'),
233
+ pageId,
234
+ userId: 'user1',
235
+ emoji: '👍',
236
+ })
237
+ // the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id
238
+ expect(() =>
239
+ commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' })
240
+ ).toThrow()
241
+ expect(() =>
242
+ commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) })
243
+ ).toThrow()
244
+ })
245
+
246
+ it('createCommentReactionId is injective when a part contains a colon', () => {
247
+ // two different triples that a naive slice+join would collapse onto one id:
248
+ // comment "foo" + user "bar:baz" + 👍 vs comment "foo:bar" + user "baz" + 👍
249
+ const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍')
250
+ const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍')
251
+ expect(a).not.toBe(b)
252
+ expect(isCommentReactionId(a)).toBe(true)
253
+ expect(isCommentReactionId(b)).toBe(true)
254
+ })
255
+ })
@@ -0,0 +1,446 @@
1
+ import { BaseRecord, RecordId, createRecordMigrationSequence } from '@tldraw/store'
2
+ import { JsonObject } from '@tldraw/utils'
3
+ import { T } from '@tldraw/validate'
4
+ import { idValidator } from '../misc/id-validator'
5
+ import { richTextValidator, TLRichText } from '../misc/TLRichText'
6
+ import { CustomRecordInfo, createCustomRecordId, isCustomRecordId } from './TLCustomRecord'
7
+ import { TLPageId } from './TLPage'
8
+ import { TLShapeId } from './TLShape'
9
+
10
+ /**
11
+ * Where a comment thread is anchored on the canvas. Modeled as a discriminated union so new
12
+ * anchor kinds can be added without breaking existing threads:
13
+ *
14
+ * - `shape` — pinned to a shape. `x`/`y` are normalized (0–1) within the shape's bounds, so the pin
15
+ * keeps its spot as the shape moves, resizes, and rotates. `isPrecise` mirrors arrow bindings:
16
+ * when false (the default) the pin sits at a consumer-defined spot instead, top-right out of the
17
+ * box, and `x`/`y` are the remembered precise position
18
+ * - `point` — pinned to a fixed point on the page, in page coordinates
19
+ * - `region` — pinned to a rectangular area of the page, in page coordinates
20
+ * - `page` — a page-level thread with no spatial anchor
21
+ *
22
+ * @public
23
+ */
24
+ export type TLCommentAnchor =
25
+ | { type: 'shape'; shapeId: TLShapeId; x: number; y: number; isPrecise: boolean }
26
+ | { type: 'point'; x: number; y: number }
27
+ | {
28
+ type: 'region'
29
+ x: number
30
+ y: number
31
+ w: number
32
+ h: number
33
+ /** The normalized (0–1) corner the pin sits on — where the creating drag was released.
34
+ * Absent on older records; consumers fall back to a corner of their own choosing. */
35
+ pinX?: number
36
+ pinY?: number
37
+ }
38
+ | { type: 'page' }
39
+
40
+ /**
41
+ * A comment thread. The thread owns the anchor (where the conversation lives on the canvas) and
42
+ * the resolution state; the messages themselves are `TLComment` records pointing at the thread
43
+ * via `threadId`, ordered by `createdAt`. v1 threads are flat — no nested replies.
44
+ *
45
+ * Threads and comments are document records, but they're intended to be served through the sync
46
+ * server's object-store lane: gated by the session's `objectAccess` rather than `isReadonly`
47
+ * (so "can comment but not edit" is expressible), excluded from document snapshots and `.tldr`
48
+ * exports server-side, and persisted in a separate lane from the main document.
49
+ *
50
+ * Opt-in: register with `createTLSchema({ records: commentSchemaRecords })` on the server and
51
+ * the matching `records` option on the client — neither type is part of the default schema, and
52
+ * both sides must register them identically.
53
+ *
54
+ * @public
55
+ */
56
+ export interface TLCommentThread extends BaseRecord<'comment-thread', TLCommentThreadId> {
57
+ /** The page the thread lives on. */
58
+ pageId: TLPageId
59
+ /** Where the thread is anchored on that page. */
60
+ anchor: TLCommentAnchor
61
+ /** Who started the thread. Client-supplied; sync servers are expected to stamp/verify it. */
62
+ createdBy: string
63
+ createdAt: number
64
+ /** Resolution state: when and by whom the thread was resolved, or null while open. */
65
+ resolved: { at: number; by: string } | null
66
+ /**
67
+ * Whether the thread is soft-deleted. Clients set the flag and leave the record in place. Sync
68
+ * servers are expected to enforce it as write-once and creator-only, reject client hard
69
+ * deletes, and drop flagged threads from future room loads.
70
+ */
71
+ isDeleted: boolean
72
+ meta: JsonObject
73
+ }
74
+
75
+ /** @public */
76
+ export type TLCommentThreadId = RecordId<TLCommentThread>
77
+
78
+ /**
79
+ * A single comment message within a thread. See `TLCommentThread` for the overall model and
80
+ * sync/registration notes.
81
+ *
82
+ * Comment mutations are deliberately not undoable — create/edit them with
83
+ * `{ history: 'ignore' }` to avoid multiplayer surprises like a comment reappearing after
84
+ * someone else deleted it.
85
+ *
86
+ * @public
87
+ */
88
+ export interface TLComment extends BaseRecord<'comment', TLCommentId> {
89
+ /** The thread this comment belongs to. */
90
+ threadId: TLCommentThreadId
91
+ /** Denormalized from the thread so per-page queries don't need a join. */
92
+ pageId: TLPageId
93
+ /** See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
94
+ authorId: string
95
+ createdAt: number
96
+ /** Null until the comment is first edited. */
97
+ editedAt: number | null
98
+ /** Rich text body. Use `toRichText(...)` for plaintext input. */
99
+ body: TLRichText
100
+ /** Whether the comment is soft-deleted. Same model as `TLCommentThread.isDeleted`. */
101
+ isDeleted: boolean
102
+ meta: JsonObject
103
+ }
104
+
105
+ /** @public */
106
+ export type TLCommentId = RecordId<TLComment>
107
+
108
+ /**
109
+ * One person's emoji reaction to one comment.
110
+ *
111
+ * A reaction is its own record rather than a field on the comment: comment records are owner-only
112
+ * for updates, and a shared field would make concurrent reactions race to overwrite each other.
113
+ *
114
+ * A user holds at most one record per (comment, emoji) pair, enforced by the derived id — see
115
+ * `createCommentReactionId`. Limiting a user to one reaction overall is client-side policy (the
116
+ * commenting package's `reactionMode`), not a property of this record.
117
+ *
118
+ * @public
119
+ */
120
+ export interface TLCommentReaction extends BaseRecord<'comment-reaction', TLCommentReactionId> {
121
+ /** The comment being reacted to. */
122
+ commentId: TLCommentId
123
+ /** Denormalized from the comment, so a thread's reactions can be found without a join. */
124
+ threadId: TLCommentThreadId
125
+ /** Denormalized from the comment — see `TLComment.pageId`. */
126
+ pageId: TLPageId
127
+ /** Who reacted. See `TLCommentThread.createdBy` — same server-stamping caveat applies. */
128
+ userId: string
129
+ /** The emoji itself, as a string (e.g. `'👍'`), not a shortcode. */
130
+ emoji: string
131
+ createdAt: number
132
+ meta: JsonObject
133
+ }
134
+
135
+ /** @public */
136
+ export type TLCommentReactionId = RecordId<TLCommentReaction>
137
+
138
+ /**
139
+ * An emoji shortcode or literal. Bounded because reaction ids embed the emoji verbatim
140
+ * (see {@link createCommentReactionId}), so an unbounded value means an unbounded record id.
141
+ */
142
+ const emojiValidator = T.string.check((value) => {
143
+ if (value.length === 0 || value.length > 64) {
144
+ throw new T.ValidationError(`Expected an emoji of 1-64 characters, got ${value.length}`)
145
+ }
146
+ })
147
+
148
+ const commentAnchorValidator: T.Validator<TLCommentAnchor> = T.union('type', {
149
+ shape: T.object({
150
+ type: T.literal('shape'),
151
+ shapeId: idValidator<TLShapeId>('shape'),
152
+ x: T.number,
153
+ y: T.number,
154
+ isPrecise: T.boolean,
155
+ }),
156
+ point: T.object({
157
+ type: T.literal('point'),
158
+ x: T.number,
159
+ y: T.number,
160
+ }),
161
+ region: T.object({
162
+ type: T.literal('region'),
163
+ x: T.number,
164
+ y: T.number,
165
+ w: T.number,
166
+ h: T.number,
167
+ pinX: T.number.optional(),
168
+ pinY: T.number.optional(),
169
+ }),
170
+ page: T.object({
171
+ type: T.literal('page'),
172
+ }),
173
+ })
174
+
175
+ /**
176
+ * Guard migrations for the comment record types. Each sequence starts with an identity migration
177
+ * that has no `down`, so a session whose schema predates the comment types is rejected with
178
+ * CLIENT_TOO_OLD instead of being sent records its store can't represent.
179
+ */
180
+ function createCommentGuardMigrations(
181
+ typeName: 'comment' | 'comment-thread' | 'comment-reaction',
182
+ extra: Parameters<typeof createRecordMigrationSequence>[0]['sequence'] = []
183
+ ) {
184
+ return createRecordMigrationSequence({
185
+ sequenceId: `com.tldraw.${typeName}`,
186
+ recordType: typeName,
187
+ retroactive: true,
188
+ sequence: [
189
+ {
190
+ id: `com.tldraw.${typeName}/1`,
191
+ up: (record) => record,
192
+ },
193
+ ...extra,
194
+ ],
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Config for registering the `comment-thread` record type in a tldraw schema. Pass via
200
+ * `commentSchemaRecords`; see `TLCommentThread`.
201
+ *
202
+ * @public
203
+ */
204
+ export const commentThreadRecordConfig: CustomRecordInfo = {
205
+ scope: 'document',
206
+ migrations: createCommentGuardMigrations('comment-thread', [
207
+ {
208
+ // Shape anchors gained normalized x/y + isPrecise; existing ones were imprecise (top-right).
209
+ id: 'com.tldraw.comment-thread/2',
210
+ up: (record) => {
211
+ const anchor = (record as any).anchor
212
+ if (anchor?.type === 'shape' && anchor.x === undefined) {
213
+ ;(record as any).anchor = { ...anchor, x: 1, y: 0, isPrecise: false }
214
+ }
215
+ return record
216
+ },
217
+ down: (record) => {
218
+ const anchor = (record as any).anchor
219
+ if (anchor?.type === 'shape') {
220
+ ;(record as any).anchor = { type: 'shape', shapeId: anchor.shapeId }
221
+ }
222
+ return record
223
+ },
224
+ },
225
+ {
226
+ // Region anchors gained an optional pin corner (where the creating drag released).
227
+ id: 'com.tldraw.comment-thread/3',
228
+ up: (record) => record,
229
+ down: (record) => {
230
+ const anchor = (record as any).anchor
231
+ if (anchor?.type === 'region') {
232
+ const { pinX: _pinX, pinY: _pinY, ...rest } = anchor
233
+ ;(record as any).anchor = rest
234
+ }
235
+ return record
236
+ },
237
+ },
238
+ ]),
239
+ validator: T.object({
240
+ id: idValidator<TLCommentThreadId>('comment-thread'),
241
+ typeName: T.literal('comment-thread'),
242
+ pageId: idValidator<TLPageId>('page'),
243
+ anchor: commentAnchorValidator,
244
+ createdBy: T.string,
245
+ createdAt: T.number,
246
+ resolved: T.object({ at: T.number, by: T.string }).nullable(),
247
+ isDeleted: T.boolean,
248
+ meta: T.jsonValue,
249
+ }),
250
+ }
251
+
252
+ /**
253
+ * Config for registering the `comment` record type in a tldraw schema. Pass via
254
+ * `commentSchemaRecords`; see `TLComment`.
255
+ *
256
+ * @public
257
+ */
258
+ export const commentRecordConfig: CustomRecordInfo = {
259
+ scope: 'document',
260
+ migrations: createCommentGuardMigrations('comment'),
261
+ validator: T.object({
262
+ id: idValidator<TLCommentId>('comment'),
263
+ typeName: T.literal('comment'),
264
+ threadId: idValidator<TLCommentThreadId>('comment-thread'),
265
+ pageId: idValidator<TLPageId>('page'),
266
+ authorId: T.string,
267
+ createdAt: T.number,
268
+ editedAt: T.number.nullable(),
269
+ body: richTextValidator,
270
+ isDeleted: T.boolean,
271
+ meta: T.jsonValue,
272
+ }),
273
+ }
274
+
275
+ /**
276
+ * Config for registering the `comment-reaction` record type in a tldraw schema. Pass via
277
+ * `commentSchemaRecords`; see `TLCommentReaction`.
278
+ *
279
+ * @public
280
+ */
281
+ export const commentReactionRecordConfig: CustomRecordInfo = {
282
+ scope: 'document',
283
+ migrations: createCommentGuardMigrations('comment-reaction'),
284
+ validator: T.object({
285
+ id: idValidator<TLCommentReactionId>('comment-reaction'),
286
+ typeName: T.literal('comment-reaction'),
287
+ commentId: idValidator<TLCommentId>('comment'),
288
+ threadId: idValidator<TLCommentThreadId>('comment-thread'),
289
+ pageId: idValidator<TLPageId>('page'),
290
+ userId: T.string,
291
+ emoji: emojiValidator,
292
+ createdAt: T.number,
293
+ meta: T.jsonValue,
294
+ }),
295
+ }
296
+
297
+ /**
298
+ * The `records` map to pass to `createTLSchema` / the client `records` option so comment
299
+ * threads, comments, and reactions sync. Register the types together — one without the others
300
+ * will fail schema validation on one side of the connection.
301
+ *
302
+ * @public
303
+ */
304
+ export const commentSchemaRecords = {
305
+ 'comment-thread': commentThreadRecordConfig,
306
+ comment: commentRecordConfig,
307
+ 'comment-reaction': commentReactionRecordConfig,
308
+ }
309
+
310
+ /** @public */
311
+ export function createCommentThreadId(id?: string): TLCommentThreadId {
312
+ return createCustomRecordId('comment-thread', id) as TLCommentThreadId
313
+ }
314
+
315
+ /** @public */
316
+ export function createCommentId(id?: string): TLCommentId {
317
+ return createCustomRecordId('comment', id) as TLCommentId
318
+ }
319
+
320
+ /**
321
+ * The id of one user's reaction to one comment, derived from the (comment, user, emoji) triple
322
+ * rather than random, so the same triple always addresses the same record and two tabs converge
323
+ * instead of racing to create duplicates. The sync authorizer leans on this, so it must be
324
+ * injective: all three parts are URI-encoded before joining, so a `:` in any of them can't shift
325
+ * the boundary and collapse two triples onto one id.
326
+ *
327
+ * @public
328
+ */
329
+ export function createCommentReactionId(
330
+ commentId: TLCommentId,
331
+ userId: string,
332
+ emoji: string
333
+ ): TLCommentReactionId {
334
+ return createCustomRecordId(
335
+ 'comment-reaction',
336
+ `${encodeURIComponent(commentId)}:${encodeURIComponent(userId)}:${encodeURIComponent(emoji)}`
337
+ ) as TLCommentReactionId
338
+ }
339
+
340
+ /**
341
+ * Type guard for `TLCommentThreadId`. `isCommentId` rejects these — `comment-thread:` and
342
+ * `comment:` differ in the character after `comment`, so the prefixes never overlap.
343
+ *
344
+ * @public
345
+ */
346
+ export function isCommentThreadId(id: string): id is TLCommentThreadId {
347
+ return isCustomRecordId('comment-thread', id)
348
+ }
349
+
350
+ /**
351
+ * Type guard for `TLCommentId`. See `isCommentThreadId`.
352
+ *
353
+ * @public
354
+ */
355
+ export function isCommentId(id: string): id is TLCommentId {
356
+ return isCustomRecordId('comment', id)
357
+ }
358
+
359
+ /**
360
+ * Type guard for `TLCommentReactionId`. See `isCommentThreadId`.
361
+ *
362
+ * @public
363
+ */
364
+ export function isCommentReactionId(id: string): id is TLCommentReactionId {
365
+ return isCustomRecordId('comment-reaction', id)
366
+ }
367
+
368
+ /**
369
+ * Create a new comment thread record. Pair with `createComment` for the thread's first message.
370
+ *
371
+ * @public
372
+ */
373
+ export function createCommentThread(props: {
374
+ pageId: TLPageId
375
+ anchor: TLCommentAnchor
376
+ createdBy: string
377
+ now?: number
378
+ meta?: JsonObject
379
+ }): TLCommentThread {
380
+ return {
381
+ id: createCommentThreadId(),
382
+ typeName: 'comment-thread',
383
+ pageId: props.pageId,
384
+ anchor: props.anchor,
385
+ createdBy: props.createdBy,
386
+ createdAt: props.now ?? Date.now(),
387
+ resolved: null,
388
+ isDeleted: false,
389
+ meta: props.meta ?? {},
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Create a new comment record within a thread.
395
+ *
396
+ * @public
397
+ */
398
+ export function createComment(props: {
399
+ threadId: TLCommentThreadId
400
+ pageId: TLPageId
401
+ authorId: string
402
+ body: TLRichText
403
+ now?: number
404
+ meta?: JsonObject
405
+ }): TLComment {
406
+ return {
407
+ id: createCommentId(),
408
+ typeName: 'comment',
409
+ threadId: props.threadId,
410
+ pageId: props.pageId,
411
+ authorId: props.authorId,
412
+ createdAt: props.now ?? Date.now(),
413
+ editedAt: null,
414
+ body: props.body,
415
+ isDeleted: false,
416
+ meta: props.meta ?? {},
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Create a reaction record for one user's reaction to one comment. The id is derived from the
422
+ * comment and user (see `createCommentReactionId`), so re-reacting overwrites rather than adding.
423
+ *
424
+ * @public
425
+ */
426
+ export function createCommentReaction(props: {
427
+ commentId: TLCommentId
428
+ threadId: TLCommentThreadId
429
+ pageId: TLPageId
430
+ userId: string
431
+ emoji: string
432
+ now?: number
433
+ meta?: JsonObject
434
+ }): TLCommentReaction {
435
+ return {
436
+ id: createCommentReactionId(props.commentId, props.userId, props.emoji),
437
+ typeName: 'comment-reaction',
438
+ commentId: props.commentId,
439
+ threadId: props.threadId,
440
+ pageId: props.pageId,
441
+ userId: props.userId,
442
+ emoji: props.emoji,
443
+ createdAt: props.now ?? Date.now(),
444
+ meta: props.meta ?? {},
445
+ }
446
+ }