@scrawl-board/board 0.1.0-beta.7 → 0.1.0-beta.8

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,469 @@
1
+ declare const strokeIdBrand: unique symbol;
2
+ type StrokeId = string & {
3
+ readonly [strokeIdBrand]: "StrokeId";
4
+ };
5
+
6
+ /** Wire grammar: `asset:<namespace>:<opaque-id>`. Interpreted only by the Host. */
7
+ type AssetRef = string;
8
+
9
+ type Mat2x3 = [number, number, number, number, number, number];
10
+
11
+ /** A stable namespaced string, e.g. `com.acme.kanban/card`. */
12
+ type ObjectType = string;
13
+ type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
14
+ readonly [key: string]: JsonValue;
15
+ };
16
+ interface CustomBoardObject {
17
+ id: string;
18
+ type: ObjectType;
19
+ schemaVersion: number;
20
+ transform: Mat2x3;
21
+ /** Safe placeholder geometry, refreshed by the SDK on every valid command. */
22
+ fallback: {
23
+ bounds: {
24
+ x: number;
25
+ y: number;
26
+ width: number;
27
+ height: number;
28
+ };
29
+ label?: string;
30
+ };
31
+ lock?: {
32
+ holderId: string;
33
+ acquiredAt: number;
34
+ };
35
+ props: JsonValue;
36
+ }
37
+
38
+ interface Lockable {
39
+ locked?: boolean;
40
+ lockedBy?: string;
41
+ lockedByName?: string;
42
+ }
43
+
44
+ /**
45
+ * Per-object visibility (Phase 8) — mirrors `itemLock.ts`'s `Lockable`
46
+ * pattern exactly, but simpler: unlike a lock, hidden state carries no
47
+ * holder/ownership concept, so there's no analogue to `LockHolder`/
48
+ * `canUnlockItem`. A hidden object stays fully present in the Document
49
+ * (still serializes, persists, syncs, undoes/redoes) — it just skips
50
+ * rendering and hit-testing/selection candidacy. `hidden` absent or
51
+ * `false` means visible; this keeps every pre-Phase-8 document (which has
52
+ * no `hidden` field on any object at all) implicitly fully visible with
53
+ * zero migration needed.
54
+ */
55
+ interface Hideable {
56
+ hidden?: boolean;
57
+ }
58
+
59
+ /** A kitchen timer sitting on the board. Remaining time is derived, not ticked. */
60
+ interface KitchenTimer extends Lockable, Hideable {
61
+ id: string;
62
+ x: number;
63
+ y: number;
64
+ /** Face diameter in board units. */
65
+ size: number;
66
+ /** What you set it to — 1, 5, 10, 15 minutes. */
67
+ durationMs: number;
68
+ /** Remaining at the last start or pause. */
69
+ remainingMs: number;
70
+ /** Wall-clock ms when the current run started. Absent means paused. */
71
+ runningSince?: number;
72
+ }
73
+
74
+ interface RectangleObject extends Lockable, Hideable {
75
+ id: string;
76
+ x: number;
77
+ y: number;
78
+ width: number;
79
+ height: number;
80
+ fill?: string;
81
+ stroke?: string;
82
+ strokeWidth?: number;
83
+ /** Corner radius in board units; clamped to at most half the shorter side at render time. */
84
+ cornerRadius?: number;
85
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
86
+ opacity?: number;
87
+ /**
88
+ * Radians, about the shape's own center `(x + width/2, y - height/2)`.
89
+ * Undefined means 0 (Phase 3). `x`/`y`/`width`/`height` stay in the
90
+ * shape's own unrotated local frame — rotation is a separate, applied-last
91
+ * transform, not baked into them, matching how Stroke/CustomBoardObject
92
+ * keep geometry and placement independent via their own `matrix`.
93
+ */
94
+ rotation?: number;
95
+ }
96
+ interface EllipseObject extends Lockable, Hideable {
97
+ id: string;
98
+ x: number;
99
+ y: number;
100
+ width: number;
101
+ height: number;
102
+ fill?: string;
103
+ stroke?: string;
104
+ strokeWidth?: number;
105
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
106
+ opacity?: number;
107
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
108
+ rotation?: number;
109
+ }
110
+ /** `"none"` is a plain line with no arrowhead; today's only real head shape is `"triangle"`. New head shapes extend this union without touching `ArrowObject`'s own fields. */
111
+ type ArrowHeadStyle = "triangle" | "none";
112
+ interface LineObject extends Lockable, Hideable {
113
+ id: string;
114
+ start: BoardPoint;
115
+ end: BoardPoint;
116
+ stroke?: string;
117
+ strokeWidth?: number;
118
+ opacity?: number;
119
+ }
120
+ interface ArrowObject extends Lockable, Hideable {
121
+ id: string;
122
+ start: BoardPoint;
123
+ end: BoardPoint;
124
+ head?: ArrowHeadStyle;
125
+ stroke?: string;
126
+ strokeWidth?: number;
127
+ opacity?: number;
128
+ }
129
+ /**
130
+ * Triangle(3)/Diamond(4)/Pentagon(5)/Hexagon(6)/Octagon(8) as one shared
131
+ * type instead of five near-duplicate interfaces — a regular N-gon
132
+ * inscribed in the same `x`/`y`/`width`/`height`/`rotation` bounding box
133
+ * Rectangle already uses, parameterized by `sides`. Diamond is exactly a
134
+ * 4-sided regular polygon with vertex 0 pointing right (not up, like
135
+ * Triangle/Pentagon/Hexagon) — see `polygonGeometry.ts`'s
136
+ * `polygonStartAngle`, which encodes each side count's own vertex
137
+ * orientation so the outline always matches the legacy drag-preview shape.
138
+ */
139
+ interface PolygonObject extends Lockable, Hideable {
140
+ id: string;
141
+ x: number;
142
+ y: number;
143
+ width: number;
144
+ height: number;
145
+ sides: 3 | 4 | 5 | 6 | 8;
146
+ fill?: string;
147
+ stroke?: string;
148
+ strokeWidth?: number;
149
+ opacity?: number;
150
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
151
+ rotation?: number;
152
+ }
153
+ /** Same bounding-box/rotation convention as Rectangle; a 5-pointed star with a tuned inner-radius ratio, matching the legacy tool's own default (see `polygonGeometry.ts`'s `starPoints`). */
154
+ interface StarObject extends Lockable, Hideable {
155
+ id: string;
156
+ x: number;
157
+ y: number;
158
+ width: number;
159
+ height: number;
160
+ /** Vertex count; today's only shipped preset is 5, matching the legacy tool. */
161
+ points: number;
162
+ /** `(0, 1)` — inner vertex radius as a fraction of the outer radius. */
163
+ innerRadiusRatio: number;
164
+ fill?: string;
165
+ stroke?: string;
166
+ strokeWidth?: number;
167
+ opacity?: number;
168
+ rotation?: number;
169
+ }
170
+ /** Same bounding-box/rotation convention as Rectangle; the standard parametric heart curve (see `polygonGeometry.ts`'s `heartPoints`), no extra parameters beyond the shared shape fields. */
171
+ interface HeartObject extends Lockable, Hideable {
172
+ id: string;
173
+ x: number;
174
+ y: number;
175
+ width: number;
176
+ height: number;
177
+ fill?: string;
178
+ stroke?: string;
179
+ strokeWidth?: number;
180
+ opacity?: number;
181
+ rotation?: number;
182
+ }
183
+ /**
184
+ * A logical grouping of other board objects (Phase 3 — Selection,
185
+ * Transformation & Grouping). Deliberately has no `x`/`y`/`transform` of its
186
+ * own — a group's bounds are always derived on demand from its (recursively
187
+ * resolved) children, and "moving/rotating/scaling the group" is exactly a
188
+ * multi-object transform applied to those children, nothing more. A group
189
+ * has no renderer/mesh of its own; its only visual presence is the
190
+ * selection gizmo's bounding box while it's the current selection.
191
+ *
192
+ * `children` may itself contain other group ids (nested groups) — expanding
193
+ * a group into its leaf members is always done by the caller (recursively,
194
+ * with cycle protection), never assumed here.
195
+ */
196
+ interface GroupObject extends Lockable, Hideable {
197
+ id: string;
198
+ children: string[];
199
+ }
200
+
201
+ interface BoardPoint {
202
+ x: number;
203
+ y: number;
204
+ }
205
+ /**
206
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
207
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
208
+ * exact configured width with no pressure variance or end taper — a
209
+ * geometric outline, not an expressive ink mark.
210
+ */
211
+ type StrokeTool = "marker" | "highlighter" | "shape";
212
+ type SerializedPoint = [number, number, number, number];
213
+ interface SerializedStroke extends Lockable, Hideable {
214
+ id: string;
215
+ color: string;
216
+ baseWidth: number;
217
+ tool?: StrokeTool;
218
+ points: SerializedPoint[];
219
+ /** Omitted when identity. */
220
+ matrix?: [number, number, number, number, number, number];
221
+ clusterId?: string;
222
+ }
223
+ interface SerializedDocument {
224
+ /** Absent in every historical document; current saves always write 1. */
225
+ schemaVersion?: 1;
226
+ strokes: SerializedStroke[];
227
+ /** Absent in documents saved before notes existed. */
228
+ notes?: StickyNote[];
229
+ /** Absent in documents saved before the text tool existed. */
230
+ textBlocks?: TextBlock[];
231
+ /** Absent in documents saved before interactive tables existed. */
232
+ tables?: TableBlock[];
233
+ /** Absent in documents saved before images existed. */
234
+ images?: ImageBlock[];
235
+ /** Absent in documents saved before kitchen timers existed. */
236
+ timers?: KitchenTimer[];
237
+ /** Absent in documents saved before Custom board objects existed (ticket #22). */
238
+ customObjects?: CustomBoardObject[];
239
+ /** Absent in documents saved before semantic Rectangle objects existed (Phase 2). */
240
+ rectangles?: RectangleObject[];
241
+ /** Absent in documents saved before semantic Ellipse objects existed (Phase 2). */
242
+ ellipses?: EllipseObject[];
243
+ /** Absent in documents saved before Groups existed (Phase 3). */
244
+ groups?: GroupObject[];
245
+ /** Absent in documents saved before semantic Line objects existed (Phase 4). */
246
+ lines?: LineObject[];
247
+ /** Absent in documents saved before semantic Arrow objects existed (Phase 4). */
248
+ arrows?: ArrowObject[];
249
+ /** Absent in documents saved before semantic Polygon objects existed (Phase 4). */
250
+ polygons?: PolygonObject[];
251
+ /** Absent in documents saved before semantic Star objects existed (Phase 4). */
252
+ stars?: StarObject[];
253
+ /** Absent in documents saved before semantic Heart objects existed (Phase 4). */
254
+ hearts?: HeartObject[];
255
+ /**
256
+ * Every content-object id (every type above except comments, which are
257
+ * host-synced and never enter this schema) in paint order, back to front.
258
+ * Absent in documents saved before per-object z-order existed (Phase 3) —
259
+ * migration synthesizes a default order preserving the old fixed-Z-band
260
+ * visual stacking exactly, so an existing document never visibly changes
261
+ * on load; only an explicit reorder action touches this from then on.
262
+ */
263
+ objectOrder?: string[];
264
+ }
265
+ /**
266
+ * One collaborator's vote on a note. One per person; toggling removes it.
267
+ */
268
+ interface NoteVote {
269
+ userId: string;
270
+ name: string;
271
+ color: string;
272
+ }
273
+ /**
274
+ * A sticky note: content floating above the board at a z-offset (pillar 3 —
275
+ * depth as an organizational axis). Center position in board space.
276
+ */
277
+ interface StickyNote extends Lockable, Hideable {
278
+ id: string;
279
+ x: number;
280
+ y: number;
281
+ /** Square side length in board units. */
282
+ size: number;
283
+ /** Height above the board surface; drives shadow offset, blur, and opacity. */
284
+ zOffset: number;
285
+ color: string;
286
+ text: string;
287
+ /** One vote per collaborator. Peel follows the count. */
288
+ votes?: NoteVote[];
289
+ }
290
+ /**
291
+ * Typed text on the board surface, rendered as SDF glyphs. Position is the
292
+ * top-left corner; lines flow downward (-y). Text joins the clustering
293
+ * system like handwriting (build prompt §6.4).
294
+ */
295
+ interface TextBlock extends Lockable, Hideable {
296
+ id: string;
297
+ x: number;
298
+ y: number;
299
+ text: string;
300
+ /** Line height in board units. */
301
+ fontSize: number;
302
+ color: string;
303
+ clusterId?: string;
304
+ }
305
+ /**
306
+ * Interactive structured table on the board. Position (x, y) is top-left in board units.
307
+ * Cells are indexed as `${row},${col}` keys mapping to cell text content.
308
+ */
309
+ interface TableBlock extends Lockable, Hideable {
310
+ id: string;
311
+ x: number;
312
+ y: number;
313
+ rows: number;
314
+ cols: number;
315
+ colWidths: number[];
316
+ rowHeights: number[];
317
+ cells: Record<string, string>;
318
+ color?: string;
319
+ backgroundColor?: string;
320
+ clusterId?: string;
321
+ }
322
+ /**
323
+ * An imported image block on the board plane.
324
+ * Coordinates (x, y) represent the center of the image in board space.
325
+ */
326
+ interface ImageBlock extends Lockable, Hideable {
327
+ id: string;
328
+ /**
329
+ * A legacy, read-only data URL (or, historically, an arbitrary string) —
330
+ * never written by new code once `ref` exists. Ticket #23's Host-managed
331
+ * Assets add `ref` as the durable path going forward; `src` and `ref` are
332
+ * mutually exclusive in practice, but both fields exist on every
333
+ * `ImageBlock` so old and new objects share one shape.
334
+ */
335
+ src: string;
336
+ /** Opaque Asset reference (ticket #23); when present, `src` is ignored. */
337
+ ref?: AssetRef;
338
+ x: number;
339
+ y: number;
340
+ width: number;
341
+ height: number;
342
+ aspectRatio: number;
343
+ name?: string;
344
+ createdAt?: string;
345
+ /** Present when this image is a stamp from the pad, not a photo. */
346
+ stamp?: string;
347
+ }
348
+
349
+ declare const CURRENT_DOCUMENT_SCHEMA_VERSION: 1;
350
+ type CurrentSerializedStroke = Omit<SerializedStroke, "id"> & {
351
+ id: StrokeId;
352
+ };
353
+ interface CurrentSerializedDocument extends Required<SerializedDocument> {
354
+ schemaVersion: typeof CURRENT_DOCUMENT_SCHEMA_VERSION;
355
+ strokes: CurrentSerializedStroke[];
356
+ }
357
+
358
+ interface DocumentContext {
359
+ documentId: string;
360
+ signal: AbortSignal;
361
+ }
362
+ type LoadResult = {
363
+ state: "found";
364
+ document: CurrentSerializedDocument;
365
+ revision: string;
366
+ } | {
367
+ state: "missing";
368
+ };
369
+ /**
370
+ * Result of a whole-document `PersistenceAdapter.replace()` call (ADR 0006:
371
+ * "Whole-document writes survive only for create, clear-board and import,
372
+ * where replacing everything is the actual intent"). Revision-gated, unlike
373
+ * `applyOps` — `conflict` means `baseRevision` was stale (someone else's
374
+ * write landed first); the caller must reload and never overwrites blind.
375
+ */
376
+ type ReplaceResult = {
377
+ state: "applied";
378
+ revision: string;
379
+ } | {
380
+ state: "conflict";
381
+ currentRevision: string;
382
+ };
383
+ /**
384
+ * The one sanctioned seam for persisting a Board's Document to a Host's own
385
+ * storage — implement this against a database, an HTTP API, IndexedDB
386
+ * (see `@scrawl-board/board/local`'s `createIndexedDBPersistence`), or
387
+ * anything else. `load()` fetches the current state on connect; `applyOps()`
388
+ * streams incremental Ops as edits happen; `replace()` is only for
389
+ * whole-document writes (create, clear-board, import — see ADR 0006) and is
390
+ * revision-gated so a stale write never silently clobbers a newer one.
391
+ * Passed via `createBoardController({ adapters: { persistence } })`.
392
+ */
393
+ interface PersistenceAdapter {
394
+ load(context: DocumentContext): Promise<LoadResult>;
395
+ applyOps(context: DocumentContext, ops: readonly ControllerOp[]): Promise<ApplyOpsResult>;
396
+ replace(context: DocumentContext, document: CurrentSerializedDocument, baseRevision: string): Promise<ReplaceResult>;
397
+ }
398
+ /**
399
+ * `"reconcile"` (ticket #24) means the server authoritatively resolved the
400
+ * whole batch — some Ops it accepted, `rejectedOpIds` it didn't (a stale
401
+ * tombstoned id, a permission change, an unrecognized schema, or a
402
+ * conflicting concurrent edit). The batch is never re-queued in this case;
403
+ * the controller instead reloads authoritative state via `load()`.
404
+ */
405
+ type ApplyOpsResult = {
406
+ state: "applied";
407
+ revision: string;
408
+ } | {
409
+ state: "reconcile";
410
+ revision: string;
411
+ rejectedOpIds: readonly string[];
412
+ reason: "tombstone" | "permission" | "schema" | "conflict";
413
+ };
414
+ interface ControllerOp {
415
+ id: string;
416
+ schemaVersion: 1;
417
+ kind: "upsert" | "restore" | "remove";
418
+ objectType: "stroke" | "note" | "text" | "table" | "image" | "timer" | "rectangle" | "ellipse" | "group" | "line" | "arrow" | "polygon" | "star" | "heart" | "custom"
419
+ /**
420
+ * A whole-document paint-order sync (Phase 3), not a per-object type —
421
+ * `objectId` is always the fixed sentinel `"order"` and `payload` is
422
+ * `{ order: string[] }`. The only `objectType` with no matching
423
+ * `BoardObject`/document collection; kept in this same union (rather
424
+ * than a separate wire message) so it flows through the existing
425
+ * `PersistenceAdapter`/`CollaborationAdapter` opaquely, unchanged.
426
+ */
427
+ | "order";
428
+ objectId: string;
429
+ payload?: unknown;
430
+ /**
431
+ * This op's position in its own originating client's local sequence
432
+ * (Phase 7) — 1, 2, 3, ... per controller instance, distinct from `id`
433
+ * (an opaque, globally-unique identifier used for dedup/ack, not
434
+ * ordering) and from a server's own authoritative ordering (e.g.
435
+ * `referenceCollaborationServer.ts`'s per-room `version` counter).
436
+ * Present on every op this SDK originates locally; a remote peer's op
437
+ * carries whatever its own origin set, unchanged — never renumbered in
438
+ * transit. Absent on an op minted by decoding the legacy wire envelope
439
+ * (`scrawlOpEnvelope.ts`), which predates this field and has no
440
+ * per-client sequence concept of its own.
441
+ */
442
+ clientSequence?: number;
443
+ /**
444
+ * The `CollaboratorIdentity.id` of this op's originating client (Phase
445
+ * 7) — set for every op this SDK originates locally when `identity` is
446
+ * configured, omitted entirely otherwise (never sent as `undefined`).
447
+ * The explicit foundation for a future per-author undo filter (a local
448
+ * user's own undo should only ever touch their own ops) — no undo-stack
449
+ * behavior itself changes this phase.
450
+ */
451
+ clientId?: string;
452
+ }
453
+
454
+ interface CreateIndexedDBPersistenceOptions {
455
+ /** IndexedDB database name — change to isolate multiple boards sharing an origin. Defaults to `"scrawl-board"`. */
456
+ databaseName?: string;
457
+ /** Injectable for tests (e.g. `fake-indexeddb`) or a non-`window` runtime that still provides IndexedDB. Defaults to `globalThis.indexedDB`. */
458
+ indexedDB?: IDBFactory;
459
+ }
460
+ /**
461
+ * Creates a `PersistenceAdapter` backed by the browser's IndexedDB. One
462
+ * instance can back multiple documents (keyed by
463
+ * `DocumentContext.documentId`), stored in object-level records so a
464
+ * partial save never corrupts unrelated objects.
465
+ */
466
+ declare function createIndexedDBPersistence(options?: CreateIndexedDBPersistenceOptions): PersistenceAdapter;
467
+
468
+ export { createIndexedDBPersistence };
469
+ export type { CreateIndexedDBPersistenceOptions };
package/dist/local.js ADDED
@@ -0,0 +1,234 @@
1
+ //#region src/persistence/shared/documentCollections.ts
2
+ var COLLECTION_FIELD = {
3
+ stroke: "strokes",
4
+ note: "notes",
5
+ text: "textBlocks",
6
+ table: "tables",
7
+ image: "images",
8
+ timer: "timers",
9
+ rectangle: "rectangles",
10
+ ellipse: "ellipses",
11
+ group: "groups",
12
+ line: "lines",
13
+ arrow: "arrows",
14
+ polygon: "polygons",
15
+ star: "stars",
16
+ heart: "hearts",
17
+ custom: "customObjects"
18
+ };
19
+ var FIELD_OBJECT_TYPE = Object.fromEntries(Object.entries(COLLECTION_FIELD).map(([objectType, field]) => [field, objectType]));
20
+ function serializeObjects(objects) {
21
+ const collections = {
22
+ strokes: [],
23
+ notes: [],
24
+ textBlocks: [],
25
+ tables: [],
26
+ images: [],
27
+ timers: [],
28
+ rectangles: [],
29
+ ellipses: [],
30
+ groups: [],
31
+ lines: [],
32
+ arrows: [],
33
+ polygons: [],
34
+ stars: [],
35
+ hearts: [],
36
+ customObjects: []
37
+ };
38
+ let objectOrder = [];
39
+ for (const object of objects) {
40
+ if (object.objectType === "order") {
41
+ objectOrder = object.payload?.order ?? [];
42
+ continue;
43
+ }
44
+ collections[COLLECTION_FIELD[object.objectType]].push(object.payload);
45
+ }
46
+ return {
47
+ schemaVersion: 1,
48
+ ...collections,
49
+ objectOrder
50
+ };
51
+ }
52
+ /** The inverse of `serializeObjects` — one `StoredObject` per collection entry, keyed by that entry's own `id`. Malformed entries (no string `id`) are skipped. */
53
+ function objectsFromDocument(document) {
54
+ const result = [];
55
+ const record = document;
56
+ for (const [field, objectType] of Object.entries(FIELD_OBJECT_TYPE)) {
57
+ const items = record[field];
58
+ if (!Array.isArray(items)) continue;
59
+ for (const item of items) {
60
+ const objectId = item?.id;
61
+ if (typeof objectId !== "string") continue;
62
+ result.push({
63
+ objectType,
64
+ objectId,
65
+ payload: item
66
+ });
67
+ }
68
+ }
69
+ if (Array.isArray(document.objectOrder) && document.objectOrder.length) result.push({
70
+ objectType: "order",
71
+ objectId: "order",
72
+ payload: { order: [...document.objectOrder] }
73
+ });
74
+ return result;
75
+ }
76
+ //#endregion
77
+ //#region src/persistence/local/indexedDBPersistenceAdapter.ts
78
+ var OBJECT_STORE = "objects";
79
+ var DOCUMENT_STORE = "documents";
80
+ var DB_VERSION = 1;
81
+ function objectKey(documentId, objectId) {
82
+ return `${documentId}::${objectId}`;
83
+ }
84
+ function requestToPromise(request) {
85
+ return new Promise((resolve, reject) => {
86
+ request.onsuccess = () => resolve(request.result);
87
+ request.onerror = () => reject(request.error);
88
+ });
89
+ }
90
+ function openDatabase(factory, databaseName) {
91
+ return new Promise((resolve, reject) => {
92
+ const request = factory.open(databaseName, DB_VERSION);
93
+ request.onupgradeneeded = () => {
94
+ const db = request.result;
95
+ if (!db.objectStoreNames.contains(OBJECT_STORE)) db.createObjectStore(OBJECT_STORE, { keyPath: "key" }).createIndex("documentId", "documentId");
96
+ if (!db.objectStoreNames.contains(DOCUMENT_STORE)) db.createObjectStore(DOCUMENT_STORE, { keyPath: "documentId" });
97
+ };
98
+ request.onsuccess = () => resolve(request.result);
99
+ request.onerror = () => reject(request.error);
100
+ });
101
+ }
102
+ /**
103
+ * Creates a `PersistenceAdapter` backed by the browser's IndexedDB. One
104
+ * instance can back multiple documents (keyed by
105
+ * `DocumentContext.documentId`), stored in object-level records so a
106
+ * partial save never corrupts unrelated objects.
107
+ */
108
+ function createIndexedDBPersistence(options = {}) {
109
+ const maybeFactory = options.indexedDB ?? (typeof indexedDB !== "undefined" ? indexedDB : void 0);
110
+ if (!maybeFactory) throw new Error("createIndexedDBPersistence: no IndexedDB implementation found. Pass `indexedDB` explicitly outside a browser (e.g. in tests), or use createMemoryPersistence instead.");
111
+ const factory = maybeFactory;
112
+ const databaseName = options.databaseName ?? "scrawl-board";
113
+ let dbPromise = null;
114
+ function db() {
115
+ if (!dbPromise) dbPromise = openDatabase(factory, databaseName);
116
+ return dbPromise;
117
+ }
118
+ async function readDocumentRecord(database, documentId) {
119
+ return requestToPromise(database.transaction(DOCUMENT_STORE, "readonly").objectStore(DOCUMENT_STORE).get(documentId));
120
+ }
121
+ async function readObjects(database, documentId) {
122
+ return requestToPromise(database.transaction(OBJECT_STORE, "readonly").objectStore(OBJECT_STORE).index("documentId").getAll(documentId));
123
+ }
124
+ return {
125
+ async load(context) {
126
+ const database = await db();
127
+ const record = await readDocumentRecord(database, context.documentId);
128
+ if (!record || record.version === 0) return { state: "missing" };
129
+ return {
130
+ state: "found",
131
+ document: serializeObjects(await readObjects(database, context.documentId)),
132
+ revision: String(record.version)
133
+ };
134
+ },
135
+ async applyOps(context, ops) {
136
+ const database = await db();
137
+ const existing = await readDocumentRecord(database, context.documentId) ?? {
138
+ documentId: context.documentId,
139
+ version: 0,
140
+ tombstones: []
141
+ };
142
+ const tombstones = new Set(existing.tombstones);
143
+ const rejectedOpIds = [];
144
+ let reason = null;
145
+ const tx = database.transaction([OBJECT_STORE, DOCUMENT_STORE], "readwrite");
146
+ const objectStore = tx.objectStore(OBJECT_STORE);
147
+ for (const op of ops) {
148
+ if (op.kind === "upsert" && tombstones.has(op.objectId)) {
149
+ rejectedOpIds.push(op.id);
150
+ reason = "tombstone";
151
+ continue;
152
+ }
153
+ const key = objectKey(context.documentId, op.objectId);
154
+ if (op.kind === "remove") {
155
+ objectStore.delete(key);
156
+ tombstones.add(op.objectId);
157
+ } else {
158
+ if (op.kind === "restore") tombstones.delete(op.objectId);
159
+ const record = {
160
+ key,
161
+ documentId: context.documentId,
162
+ objectType: op.objectType,
163
+ objectId: op.objectId,
164
+ payload: op.payload
165
+ };
166
+ objectStore.put(record);
167
+ }
168
+ }
169
+ const nextVersion = ops.length ? existing.version + 1 : existing.version;
170
+ const nextRecord = {
171
+ documentId: context.documentId,
172
+ version: nextVersion,
173
+ tombstones: [...tombstones]
174
+ };
175
+ tx.objectStore(DOCUMENT_STORE).put(nextRecord);
176
+ await new Promise((resolve, reject) => {
177
+ tx.oncomplete = () => resolve();
178
+ tx.onerror = () => reject(tx.error);
179
+ tx.onabort = () => reject(tx.error);
180
+ });
181
+ if (rejectedOpIds.length && reason) return {
182
+ state: "reconcile",
183
+ revision: String(nextVersion),
184
+ rejectedOpIds,
185
+ reason
186
+ };
187
+ return {
188
+ state: "applied",
189
+ revision: String(nextVersion)
190
+ };
191
+ },
192
+ async replace(context, document, baseRevision) {
193
+ const database = await db();
194
+ const existing = await readDocumentRecord(database, context.documentId) ?? {
195
+ documentId: context.documentId,
196
+ version: 0,
197
+ tombstones: []
198
+ };
199
+ if (String(existing.version) !== baseRevision) return {
200
+ state: "conflict",
201
+ currentRevision: String(existing.version)
202
+ };
203
+ const tx = database.transaction([OBJECT_STORE, DOCUMENT_STORE], "readwrite");
204
+ const objectStore = tx.objectStore(OBJECT_STORE);
205
+ const existingKeys = await requestToPromise(objectStore.index("documentId").getAllKeys(context.documentId));
206
+ for (const key of existingKeys) objectStore.delete(key);
207
+ for (const object of objectsFromDocument(document)) {
208
+ const record = {
209
+ key: objectKey(context.documentId, object.objectId),
210
+ documentId: context.documentId,
211
+ ...object
212
+ };
213
+ objectStore.put(record);
214
+ }
215
+ const nextVersion = existing.version + 1;
216
+ tx.objectStore(DOCUMENT_STORE).put({
217
+ documentId: context.documentId,
218
+ version: nextVersion,
219
+ tombstones: []
220
+ });
221
+ await new Promise((resolve, reject) => {
222
+ tx.oncomplete = () => resolve();
223
+ tx.onerror = () => reject(tx.error);
224
+ tx.onabort = () => reject(tx.error);
225
+ });
226
+ return {
227
+ state: "applied",
228
+ revision: String(nextVersion)
229
+ };
230
+ }
231
+ };
232
+ }
233
+ //#endregion
234
+ export { createIndexedDBPersistence };