edytor 0.1.0-next.24 → 0.1.0-next.25

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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/cloudflare/DocumentRoom.d.ts +22 -0
  3. package/dist/cloudflare/DocumentRoom.js +75 -14
  4. package/dist/crdt/data.js +2 -1
  5. package/dist/crdt/document.d.ts +151 -0
  6. package/dist/crdt/document.js +11 -146
  7. package/dist/crdt/edytor-doc.d.ts +117 -4
  8. package/dist/crdt/edytor-doc.js +2 -0
  9. package/dist/crdt/engine.d.ts +3 -1
  10. package/dist/crdt/engine.js +4 -1
  11. package/dist/crdt/index.d.ts +21 -4
  12. package/dist/crdt/index.js +25 -1
  13. package/dist/crdt/migration/generation.d.ts +58 -0
  14. package/dist/crdt/migration/generation.js +68 -0
  15. package/dist/crdt/migration/migrate.js +1 -1
  16. package/dist/crdt/placement/model.d.ts +32 -0
  17. package/dist/crdt/placement/model.js +151 -7
  18. package/dist/crdt/placement/rank.d.ts +38 -24
  19. package/dist/crdt/placement/rank.js +147 -93
  20. package/dist/crdt/protocols/envelope.d.ts +9 -3
  21. package/dist/crdt/protocols/envelope.js +9 -3
  22. package/dist/crdt/protocols/sync.d.ts +13 -6
  23. package/dist/crdt/protocols/sync.js +31 -9
  24. package/dist/crdt/providers/index.js +2 -1
  25. package/dist/crdt/providers/indexeddb.d.ts +10 -0
  26. package/dist/crdt/providers/indexeddb.js +53 -6
  27. package/dist/crdt/providers/room.js +16 -1
  28. package/dist/crdt/schema.d.ts +1 -1
  29. package/dist/crdt/schema.js +4 -1
  30. package/dist/crdt/semantics.d.ts +46 -1
  31. package/dist/crdt/semantics.js +32 -3
  32. package/dist/crdt/text/deletes.js +102 -18
  33. package/dist/crdt/text/marks.d.ts +58 -0
  34. package/dist/crdt/text/marks.js +85 -0
  35. package/dist/crdt/text/model.js +15 -8
  36. package/dist/crdt/text/runs.js +48 -3
  37. package/dist/crdt/vendor/yjs/UPSTREAM.md +40 -0
  38. package/dist/crdt/vendor/yjs/dts/utils/Doc.d.ts +6 -0
  39. package/dist/crdt/vendor/yjs/dts/utils/marks.d.ts +83 -0
  40. package/dist/crdt/vendor/yjs/dts/utils/transaction-helpers.d.ts +1 -1
  41. package/dist/crdt/vendor/yjs/dts/ynode.d.ts +25 -0
  42. package/dist/crdt/vendor/yjs/src/structs/Item.js +12 -2
  43. package/dist/crdt/vendor/yjs/src/utils/Doc.js +6 -0
  44. package/dist/crdt/vendor/yjs/src/utils/RangeCursor.js +2 -0
  45. package/dist/crdt/vendor/yjs/src/utils/marks.js +149 -0
  46. package/dist/crdt/vendor/yjs/src/utils/transaction-helpers.js +10 -4
  47. package/dist/crdt/vendor/yjs/src/ynode.js +226 -2
  48. package/dist/edytor.svelte.js +19 -2
  49. package/dist/plugins/richtext/RichTextPlugin.svelte +2 -2
  50. package/dist/session/editing/text.d.ts +6 -2
  51. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
17
17
 
18
- > **Work in progress.** Edytor is a pre-release (`0.1.0-next.24`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
18
+ > **Work in progress.** Edytor is a pre-release (`0.1.0-next.25`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
19
19
 
20
20
  ## Features
21
21
 
@@ -317,6 +317,12 @@ export type RoomLogEntry = {
317
317
  edytor: 'fault';
318
318
  reason: 'storage' | 'internal';
319
319
  detail: string;
320
+ } | {
321
+ edytor: 'convert';
322
+ from: number;
323
+ to: number;
324
+ blocks: number;
325
+ bytes: number;
320
326
  };
321
327
  /** `pending`: deletes of items the room does not hold yet (they wait for them). */
322
328
  type RowKind = 'generation' | 'update' | 'snapshot' | 'pending';
@@ -481,6 +487,10 @@ export declare class AttachedDocument {
481
487
  } | {
482
488
  kind: 'restored';
483
489
  records: number;
490
+ } | {
491
+ kind: 'converted';
492
+ from: number;
493
+ records: number;
484
494
  };
485
495
  private nextRecord;
486
496
  private updates;
@@ -651,6 +661,18 @@ export declare class AttachedDocument {
651
661
  * engine refuses) is refused for good.
652
662
  */
653
663
  private load;
664
+ /**
665
+ * The generation cutover at load (`room.generation.convert`): the
666
+ * records of a generation-4 container are read as JSON
667
+ * (`crdt.generations.previousJSON`), which this generation seeds as
668
+ * `onLoad`'s JSON is (deterministic: every replica converting the same
669
+ * state writes the same update), and the container is replaced by it in
670
+ * one storage transaction: rows, replicas, purge epochs, slot editors
671
+ * and the stored restore go; the document data, the alarm's due tasks
672
+ * and `lastUpdated` stay. What a generation-4 client never sent is not
673
+ * in it; history, attribution and CRDT identity do not cross.
674
+ */
675
+ private convert;
654
676
  /** The bytes of the stored `pending` records (uncompressed, as every record but a snapshot). */
655
677
  private pendingBytes;
656
678
  /**
@@ -264,17 +264,7 @@ const knob = (value, fallback, max = fallback) => {
264
264
  return Number.isInteger(n) && n > 0 ? Math.min(n, max) : fallback;
265
265
  };
266
266
  /** A semantics config as the facade's lookups — own keys only: block types come off the wire. */
267
- const lookups = (semantics) => {
268
- const own = (table = {}) => (type) => Object.hasOwn(table, type) ? table[type] : undefined;
269
- const rendersContent = own(semantics.rendersContent);
270
- return {
271
- roleOf: own(semantics.roles),
272
- kinds: () => Object.keys(semantics.roles ?? {}),
273
- defaultChildOf: own(semantics.defaultChild),
274
- rendersContent: (type) => rendersContent(type) ?? true,
275
- defaultType: semantics.defaultType
276
- };
277
- };
267
+ const lookups = (semantics) => E.facadeConfigOf(semantics);
278
268
  const encodeJSON = (value) => new TextEncoder().encode(JSON.stringify(value));
279
269
  /** An awareness frame carrying `entries` — no `Awareness` instance involved. */
280
270
  export const presenceFrame = (entries) => E.frame(E.messageAwareness, (e) => E.writeVarUint8Array(e, E.writeAwarenessEntries(entries)));
@@ -652,6 +642,8 @@ const roomDoc = (keep) => {
652
642
  return doc;
653
643
  };
654
644
  /** Admit stored or loaded updates into a room document (`prepareRoomDoc`'s collection rules). */
645
+ /** How many blocks `children` holds, nested ones included. */
646
+ const countBlocks = (children) => children.reduce((n, block) => n + 1 + countBlocks(block.children ?? []), 0);
655
647
  const admit = (updates, name, keep) => crdt.admission.admitUpdate(updates, name, { prepare: (doc) => prepareRoomDoc(doc, keep) });
656
648
  // ── Per-writer marks (H2) ──────────────────────────────────────────
657
649
  /** The writer `n` of a per-writer block mark key (`del.<n>`, `wd.<n>`), or `null`. */
@@ -1400,6 +1392,10 @@ export class AttachedDocument {
1400
1392
  }
1401
1393
  const [generation, ...rest] = records;
1402
1394
  const found = JSON.parse(new TextDecoder().decode(generation.bytes));
1395
+ // A container of generation 4 converts through its JSON (`room.generation.convert`).
1396
+ if (generation.kind === 'generation' && E.isPreviousGenerationRecord(found)) {
1397
+ return this.convert(rest);
1398
+ }
1403
1399
  if (generation.kind !== 'generation' || !isGenerationRecord(found)) {
1404
1400
  throw new E.GenerationMismatchError(`room ${this.ctx.id}`, found);
1405
1401
  }
@@ -1418,6 +1414,57 @@ export class AttachedDocument {
1418
1414
  this.fail(error, false);
1419
1415
  }
1420
1416
  }
1417
+ /**
1418
+ * The generation cutover at load (`room.generation.convert`): the
1419
+ * records of a generation-4 container are read as JSON
1420
+ * (`crdt.generations.previousJSON`), which this generation seeds as
1421
+ * `onLoad`'s JSON is (deterministic: every replica converting the same
1422
+ * state writes the same update), and the container is replaced by it in
1423
+ * one storage transaction: rows, replicas, purge epochs, slot editors
1424
+ * and the stored restore go; the document data, the alarm's due tasks
1425
+ * and `lastUpdated` stay. What a generation-4 client never sent is not
1426
+ * in it; history, attribution and CRDT identity do not cross.
1427
+ */
1428
+ convert(records) {
1429
+ const json = crdt.generations.previousJSON(records.map((record) => (record.v2 ? { v2: record.bytes } : record.bytes)), (doc) => this.facadeOf(doc));
1430
+ const scratch = crdt.createDoc();
1431
+ const facade = this.facadeOf(scratch);
1432
+ facade.seed(json.children, json.data);
1433
+ facade.dispose();
1434
+ const update = Y.encodeStateAsUpdate(scratch);
1435
+ scratch.destroy();
1436
+ const doc = admit(update, `room ${this.ctx.id} conversion`, () => this.restoreKeep);
1437
+ const snapshot = liveState(doc);
1438
+ this.ctx.storage.transactionSync(() => {
1439
+ for (const table of [
1440
+ this.rowsTable,
1441
+ this.replicasTable,
1442
+ this.epochsTable,
1443
+ this.editorsTable,
1444
+ this.restoreTable
1445
+ ])
1446
+ this.sql.exec(`DELETE FROM ${table}`);
1447
+ this.nextRecord = 0;
1448
+ this.insert('snapshot', snapshot);
1449
+ this.touch();
1450
+ for (const replica of stateVector(doc).keys())
1451
+ this.sql.exec(`INSERT OR IGNORE INTO ${this.replicasTable} (replica, user) VALUES (?, '')`, replica);
1452
+ });
1453
+ this.restoreStep = null;
1454
+ this.format = STORED_GENERATION_RECORD.storage;
1455
+ this.documentBytes = snapshot.length;
1456
+ this.updates = 0;
1457
+ this.storedWaiting = Y.createIdSet();
1458
+ this.adopt(doc);
1459
+ this.origin = { kind: 'converted', from: E.PREVIOUS_SCHEMA, records: records.length };
1460
+ this.log({
1461
+ edytor: 'convert',
1462
+ from: E.PREVIOUS_SCHEMA,
1463
+ to: E.SCHEMA_VERSION,
1464
+ blocks: countBlocks(json.children),
1465
+ bytes: snapshot.length
1466
+ });
1467
+ }
1421
1468
  /** The bytes of the stored `pending` records (uncompressed, as every record but a snapshot). */
1422
1469
  pendingBytes() {
1423
1470
  return (this.sql
@@ -1441,7 +1488,7 @@ export class AttachedDocument {
1441
1488
  if (records[0]?.kind === 'generation') {
1442
1489
  try {
1443
1490
  const found = JSON.parse(new TextDecoder().decode(records[0].bytes));
1444
- if (isGenerationRecord(found))
1491
+ if (isGenerationRecord(found) || E.isPreviousGenerationRecord(found))
1445
1492
  format = storageOf(found);
1446
1493
  }
1447
1494
  catch {
@@ -1831,7 +1878,17 @@ export class AttachedDocument {
1831
1878
  return null;
1832
1879
  const bytes = await gunzip(new Uint8Array(value));
1833
1880
  return noTimers(() => {
1834
- const doc = admit([{ v2: bytes }], `room ${this.ctx.id} version ${key}`, () => null);
1881
+ let doc;
1882
+ try {
1883
+ doc = admit([{ v2: bytes }], `room ${this.ctx.id} version ${key}`, () => null);
1884
+ }
1885
+ catch (error) {
1886
+ // A version written by generation 4 reads through its JSON (`room.generation.convert`).
1887
+ const problem = error.problem;
1888
+ if (problem?.kind !== 'unsupported' || problem.version !== E.PREVIOUS_SCHEMA)
1889
+ throw error;
1890
+ return crdt.generations.previousJSON([{ v2: bytes }], (d) => this.facadeOf(d));
1891
+ }
1835
1892
  const facade = this.facadeOf(doc);
1836
1893
  try {
1837
1894
  return facade.toJSON();
@@ -2688,7 +2745,11 @@ export class AttachedDocument {
2688
2745
  if (syncType !== E.messageYjsSyncStep2 && syncType !== E.messageYjsUpdate) {
2689
2746
  return this.refuse(ws, { reason: 'malformed', detail: `sync type ${syncType}` });
2690
2747
  }
2691
- const update = decode(() => E.readVarUint8Array(decoder));
2748
+ // A SyncStep2 is v2 on the wire (P5): every path below reads v1.
2749
+ const update = decode(() => {
2750
+ const payload = E.readVarUint8Array(decoder);
2751
+ return syncType === E.messageYjsSyncStep2 ? sync.step2Update(payload) : payload;
2752
+ });
2692
2753
  const started = now();
2693
2754
  try {
2694
2755
  this.onUpdate(ws, attachment, doc, update);
package/dist/crdt/data.js CHANGED
@@ -15,7 +15,8 @@ const itemish = (s) => /^~[a-z]/.test(s);
15
15
  export const keySegment = (k) => (k.startsWith('~') ? `~0${k.slice(1)}` : k);
16
16
  /** The object key a path segment names (`keySegment`'s inverse). */
17
17
  export const segmentKey = (s) => (s.startsWith('~0') ? `~${s.slice(2)}` : s);
18
- const RANK = /^(?:[-\w]{16})+$/;
18
+ /** A rank's characters (`placement/rank.ts`, P7: variable-length digits, `!` before a tie). */
19
+ const RANK = /^[-\w?!]+$/;
19
20
  const index = (k) => (/^(0|[1-9]\d*)$/.test(k) ? Number(k) : -1);
20
21
  const rank = (v) => (typeof v === 'string' && RANK.test(v) ? v : undefined);
21
22
  /** Item `id`'s place: where a move put it (`id>`), else where it was made (`id#`). */
@@ -1,3 +1,146 @@
1
+ /**
2
+ * `EdytorDocument` — the assembled document: one composition/lifecycle
3
+ * owner binding the production engine once and composing the existing
4
+ * domain facade, the default local history, one shared awareness, the
5
+ * document-level semantic configuration (block roles + default type) and
6
+ * the local actor identity (compact per-block attribution, U1+U2).
7
+ *
8
+ * Used headlessly or shared by any number of `Edytor` views:
9
+ *
10
+ * ```ts
11
+ * const document = createDocument({ value, actor: { id: 'user-42', name: 'Ada' } });
12
+ * document.facade.insertText(bootstrapId, 0, 'hello');
13
+ * document.history.undo();
14
+ * const saved = document.encode();
15
+ * const restored = loadDocument(saved);
16
+ * ```
17
+ *
18
+ * ```svelte
19
+ * <Edytor {document} />
20
+ * ```
21
+ *
22
+ * ── Owned vs borrowed ──────────────────────────────────────────────────
23
+ *
24
+ * `createDocument`/`loadDocument` allocate a FRESH engine doc — the
25
+ * document owns it and `destroy()` releases everything exactly once
26
+ * (undo manager, facade/run-view lease, awareness, doc, sync cleanups).
27
+ * `attachDocument` composes the same services around a doc the CALLER
28
+ * owns: `destroy()` releases the document-created services but never
29
+ * `doc.destroy()`s the borrowed doc (borrowing grants no destruction
30
+ * authority — U1 contract). The same rule applies to an injected
31
+ * `awareness` (the `_ownsAwareness` convention already used by
32
+ * `IndexeddbPersistence`).
33
+ *
34
+ * ── Readiness ──────────────────────────────────────────────────────────
35
+ *
36
+ * `createDocument({ value })` seeds synchronously (local-first) and is
37
+ * `ready` on return. A document created WITHOUT a value stays `pending`:
38
+ * no bootstrap is stamped, no version written, because a provider may
39
+ * still hydrate it. `sync(value?)` is the single explicit readiness
40
+ * transition — the same deferral `Edytor.sync()` had: assert-and-adopt
41
+ * on an already-initialized (hydrated) doc, `init` on a still-fresh one,
42
+ * then the history attaches (bootstrap-before-capture — `createUndoManager`
43
+ * on the facade keeps that invariant internally too). `loadDocument`
44
+ * validates the payload BEFORE the facade or any capture attaches, then
45
+ * arrives ready.
46
+ *
47
+ * Per-state legality: `pending` — reads (`facade.project`/`toJSON`),
48
+ * `attachSync`, `adoptSemantics`, `trackOrigin`, `sync()`, `destroy()`
49
+ * are legal; `history` throws {@link DocumentNotReadyError}; nothing
50
+ * bootstrap-shaped is written or broadcast, so writes made raw against a
51
+ * pending doc produce unversioned state that `sync()` then refuses (see
52
+ * below). `local`/`hydrated` — the full surface is legal; the only
53
+ * difference is WHO decided the content. (One exception to "nothing
54
+ * broadcast": the actor dictionary attach publishes this replica's
55
+ * `c/<clientID>`/`u/<actorId>` records on ANY constructed document —
56
+ * identity metadata, not content, and outside every admission gate.)
57
+ *
58
+ * ── Admission boundary (U8) ────────────────────────────────────────────
59
+ *
60
+ * Every content-entry path crosses the one document-level admission gate
61
+ * (`src/lib/crdt/admission.ts` — shared vocabulary with the transport
62
+ * layer's staged-update gate):
63
+ *
64
+ * - `createDocument` — the fresh doc is trivially admitted;
65
+ * - `loadDocument` — the payload is decoded+integrated onto a SCRATCH
66
+ * doc first (`admitUpdate`) and the gate runs on the merged result; a
67
+ * refusal ({@link UndecodableUpdateError}, {@link UnsupportedDocError},
68
+ * {@link SchemaMismatchError}) leaves nothing composed and never
69
+ * touches the caller's bytes;
70
+ * - `attachDocument` — the borrowed doc is gated BEFORE any composition
71
+ * touches it; a refusal leaves it byte-identical (and re-attachable
72
+ * once its state heals). It never auto-seeds: the doc always attaches
73
+ * `pending` and only `sync()`/migration decides content state;
74
+ * - `sync()` — re-runs the gate at the readiness decision: provider or
75
+ * raw writes may have landed since attach. A doc that fails now
76
+ * refuses readiness with the typed error (stays `pending`, content
77
+ * preserved) rather than `init` stamping a schema over unversioned
78
+ * content; a `'fresh'` verdict seeds (`local`), an `'initialized'`
79
+ * verdict adopts (`hydrated`). Idempotent once ready.
80
+ *
81
+ * Refusal preserves data — the gate reads are write-free: refused docs
82
+ * keep their content, refused updates keep their bytes, and provider-side
83
+ * refusals keep their stored rows (`IndexeddbPersistence` compaction
84
+ * stays blocked while a refused row set cannot be represented by a
85
+ * compacted snapshot).
86
+ *
87
+ * ── Semantic configuration ─────────────────────────────────────────────
88
+ *
89
+ * The adopted capability (rule R5) is document-level state that outlives
90
+ * any single view: structural roles (`void`/`island`), whether a kind
91
+ * renders its own content (`rendersContent`), the default child type per
92
+ * parent type (`defaultChild`) and `defaultType`. Views contribute what
93
+ * their plugin block definitions declare via {@link adoptSemantics}: the
94
+ * first declaration for a type is adopted, a later CONFLICTING one is an
95
+ * error, and a refused contribution adopts nothing (views cannot silently
96
+ * impose incompatible structural rules on a shared document). Snippets
97
+ * and DOM hooks stay view-side.
98
+ *
99
+ * ── History ────────────────────────────────────────────────────────────
100
+ *
101
+ * One registry-scoped `Y.UndoManager` per document (the facade's
102
+ * supported seam). Capture is gated by TWO live checks, both consulted
103
+ * on every committing transaction:
104
+ *
105
+ * - `trackedOrigins` — a LIVE per-document set checked by identity: the
106
+ * document's own headless origin, `null` (untyped local transactions)
107
+ * and every view's `TRANSACTION` instance registered via
108
+ * {@link trackOrigin} — each `Edytor` keeps its own instance so the
109
+ * mirror can still tell its own commits from a sibling view's, while
110
+ * identity tracking keeps undo LOCAL unless the document is actually
111
+ * shared.
112
+ * - `captureTransaction` — `transaction.local !== false`. The engine
113
+ * marks every update-integration transaction non-local: `applyUpdate`
114
+ * runs `transact(..., local=false)` AND forces `transaction.local =
115
+ * false` on the in-flight transaction, so even an apply nested inside
116
+ * a local `transact` marks the whole commit non-local. This is the
117
+ * remote-exclusion contract: NO provider path or raw
118
+ * `applyUpdate(doc, u)` — stamped or originless — can ever enter the
119
+ * local user undo stack, and the guarantee cannot be bypassed by a
120
+ * provider forgetting to stamp an origin. (Provider paths still stamp
121
+ * their instance as origin — required anyway so `doc.on('update')`
122
+ * echo suppression keeps working — see `protocols/sync.ts`
123
+ * `remoteApplyOrigin` for the residual no-origin default.)
124
+ *
125
+ * `null` therefore carries only LOCAL untyped transactions now — direct
126
+ * `document.facade.<op>` calls outside `document.transact`, raw
127
+ * `doc.transact(fn)` writes. Keeping it tracked preserves the headless
128
+ * contract (`document.facade.insertText(...)` stays undoable); callers
129
+ * wanting per-action undo grouping should prefer `document.transact`.
130
+ * Bookkeeping writes that must never enter user history run under an
131
+ * UNTRACKED origin (the undo-ownership repair's `UNDO_REPAIR_ORIGIN`
132
+ * Symbol and the actor-dictionary's `ATTRIBUTION_ORIGIN` are the existing
133
+ * examples) or inside a non-local transaction; either channel is
134
+ * sufficient and both are free of the local undo stack.
135
+ *
136
+ * History is lazy: it attaches at `sync()`, or on first `history` access
137
+ * once the doc is initialized — it NEVER initializes content itself, so
138
+ * bootstrap/hydration writes all predate capture and are naturally
139
+ * excluded. Stacks are unbounded (the engine prunes nothing); prune
140
+ * explicitly via {@link clearHistory} / `history.clear()`, and tune the
141
+ * merge window via `history.captureTimeout`.
142
+ */
143
+ import type { MarkEdge } from './text/marks.js';
1
144
  import type { EngineApi, YDoc, YUndoManager } from './engine-api.js';
2
145
  import { type AttributionBinding, type DocumentAttribution } from './attribution/index.js';
3
146
  import { type BlockRole, type EdytorDoc, type EdytorDocBinding } from './edytor-doc.js';
@@ -32,6 +175,13 @@ export type DocumentSemanticsConfig = {
32
175
  defaultChild?: Record<string, string>;
33
176
  /** Default block type — the root's default child and the bootstrap block. */
34
177
  defaultType?: string;
178
+ /**
179
+ * H5: per mark, its edge (its record's `edge`) — where a concurrent insert
180
+ * at each end of a mark lands. Undeclared marks are `inclusive`.
181
+ */
182
+ marks?: Record<string, {
183
+ edge?: MarkEdge;
184
+ }>;
35
185
  };
36
186
  export type DocumentOptions = {
37
187
  /** Local actor — anonymous opaque id when absent. */
@@ -340,6 +490,7 @@ export declare class EdytorDocument {
340
490
  roles: Map<string, BlockRole>;
341
491
  rendersContent: Map<string, boolean>;
342
492
  defaultChild: Map<string, string>;
493
+ marks: Map<string, MarkEdge>;
343
494
  };
344
495
  /**
345
496
  * The default child type under a parent of `parentType` (`null` = the
@@ -1,145 +1,3 @@
1
- /**
2
- * `EdytorDocument` — the assembled document: one composition/lifecycle
3
- * owner binding the production engine once and composing the existing
4
- * domain facade, the default local history, one shared awareness, the
5
- * document-level semantic configuration (block roles + default type) and
6
- * the local actor identity (compact per-block attribution, U1+U2).
7
- *
8
- * Used headlessly or shared by any number of `Edytor` views:
9
- *
10
- * ```ts
11
- * const document = createDocument({ value, actor: { id: 'user-42', name: 'Ada' } });
12
- * document.facade.insertText(bootstrapId, 0, 'hello');
13
- * document.history.undo();
14
- * const saved = document.encode();
15
- * const restored = loadDocument(saved);
16
- * ```
17
- *
18
- * ```svelte
19
- * <Edytor {document} />
20
- * ```
21
- *
22
- * ── Owned vs borrowed ──────────────────────────────────────────────────
23
- *
24
- * `createDocument`/`loadDocument` allocate a FRESH engine doc — the
25
- * document owns it and `destroy()` releases everything exactly once
26
- * (undo manager, facade/run-view lease, awareness, doc, sync cleanups).
27
- * `attachDocument` composes the same services around a doc the CALLER
28
- * owns: `destroy()` releases the document-created services but never
29
- * `doc.destroy()`s the borrowed doc (borrowing grants no destruction
30
- * authority — U1 contract). The same rule applies to an injected
31
- * `awareness` (the `_ownsAwareness` convention already used by
32
- * `IndexeddbPersistence`).
33
- *
34
- * ── Readiness ──────────────────────────────────────────────────────────
35
- *
36
- * `createDocument({ value })` seeds synchronously (local-first) and is
37
- * `ready` on return. A document created WITHOUT a value stays `pending`:
38
- * no bootstrap is stamped, no version written, because a provider may
39
- * still hydrate it. `sync(value?)` is the single explicit readiness
40
- * transition — the same deferral `Edytor.sync()` had: assert-and-adopt
41
- * on an already-initialized (hydrated) doc, `init` on a still-fresh one,
42
- * then the history attaches (bootstrap-before-capture — `createUndoManager`
43
- * on the facade keeps that invariant internally too). `loadDocument`
44
- * validates the payload BEFORE the facade or any capture attaches, then
45
- * arrives ready.
46
- *
47
- * Per-state legality: `pending` — reads (`facade.project`/`toJSON`),
48
- * `attachSync`, `adoptSemantics`, `trackOrigin`, `sync()`, `destroy()`
49
- * are legal; `history` throws {@link DocumentNotReadyError}; nothing
50
- * bootstrap-shaped is written or broadcast, so writes made raw against a
51
- * pending doc produce unversioned state that `sync()` then refuses (see
52
- * below). `local`/`hydrated` — the full surface is legal; the only
53
- * difference is WHO decided the content. (One exception to "nothing
54
- * broadcast": the actor dictionary attach publishes this replica's
55
- * `c/<clientID>`/`u/<actorId>` records on ANY constructed document —
56
- * identity metadata, not content, and outside every admission gate.)
57
- *
58
- * ── Admission boundary (U8) ────────────────────────────────────────────
59
- *
60
- * Every content-entry path crosses the one document-level admission gate
61
- * (`src/lib/crdt/admission.ts` — shared vocabulary with the transport
62
- * layer's staged-update gate):
63
- *
64
- * - `createDocument` — the fresh doc is trivially admitted;
65
- * - `loadDocument` — the payload is decoded+integrated onto a SCRATCH
66
- * doc first (`admitUpdate`) and the gate runs on the merged result; a
67
- * refusal ({@link UndecodableUpdateError}, {@link UnsupportedDocError},
68
- * {@link SchemaMismatchError}) leaves nothing composed and never
69
- * touches the caller's bytes;
70
- * - `attachDocument` — the borrowed doc is gated BEFORE any composition
71
- * touches it; a refusal leaves it byte-identical (and re-attachable
72
- * once its state heals). It never auto-seeds: the doc always attaches
73
- * `pending` and only `sync()`/migration decides content state;
74
- * - `sync()` — re-runs the gate at the readiness decision: provider or
75
- * raw writes may have landed since attach. A doc that fails now
76
- * refuses readiness with the typed error (stays `pending`, content
77
- * preserved) rather than `init` stamping a schema over unversioned
78
- * content; a `'fresh'` verdict seeds (`local`), an `'initialized'`
79
- * verdict adopts (`hydrated`). Idempotent once ready.
80
- *
81
- * Refusal preserves data — the gate reads are write-free: refused docs
82
- * keep their content, refused updates keep their bytes, and provider-side
83
- * refusals keep their stored rows (`IndexeddbPersistence` compaction
84
- * stays blocked while a refused row set cannot be represented by a
85
- * compacted snapshot).
86
- *
87
- * ── Semantic configuration ─────────────────────────────────────────────
88
- *
89
- * The adopted capability (rule R5) is document-level state that outlives
90
- * any single view: structural roles (`void`/`island`), whether a kind
91
- * renders its own content (`rendersContent`), the default child type per
92
- * parent type (`defaultChild`) and `defaultType`. Views contribute what
93
- * their plugin block definitions declare via {@link adoptSemantics}: the
94
- * first declaration for a type is adopted, a later CONFLICTING one is an
95
- * error, and a refused contribution adopts nothing (views cannot silently
96
- * impose incompatible structural rules on a shared document). Snippets
97
- * and DOM hooks stay view-side.
98
- *
99
- * ── History ────────────────────────────────────────────────────────────
100
- *
101
- * One registry-scoped `Y.UndoManager` per document (the facade's
102
- * supported seam). Capture is gated by TWO live checks, both consulted
103
- * on every committing transaction:
104
- *
105
- * - `trackedOrigins` — a LIVE per-document set checked by identity: the
106
- * document's own headless origin, `null` (untyped local transactions)
107
- * and every view's `TRANSACTION` instance registered via
108
- * {@link trackOrigin} — each `Edytor` keeps its own instance so the
109
- * mirror can still tell its own commits from a sibling view's, while
110
- * identity tracking keeps undo LOCAL unless the document is actually
111
- * shared.
112
- * - `captureTransaction` — `transaction.local !== false`. The engine
113
- * marks every update-integration transaction non-local: `applyUpdate`
114
- * runs `transact(..., local=false)` AND forces `transaction.local =
115
- * false` on the in-flight transaction, so even an apply nested inside
116
- * a local `transact` marks the whole commit non-local. This is the
117
- * remote-exclusion contract: NO provider path or raw
118
- * `applyUpdate(doc, u)` — stamped or originless — can ever enter the
119
- * local user undo stack, and the guarantee cannot be bypassed by a
120
- * provider forgetting to stamp an origin. (Provider paths still stamp
121
- * their instance as origin — required anyway so `doc.on('update')`
122
- * echo suppression keeps working — see `protocols/sync.ts`
123
- * `remoteApplyOrigin` for the residual no-origin default.)
124
- *
125
- * `null` therefore carries only LOCAL untyped transactions now — direct
126
- * `document.facade.<op>` calls outside `document.transact`, raw
127
- * `doc.transact(fn)` writes. Keeping it tracked preserves the headless
128
- * contract (`document.facade.insertText(...)` stays undoable); callers
129
- * wanting per-action undo grouping should prefer `document.transact`.
130
- * Bookkeeping writes that must never enter user history run under an
131
- * UNTRACKED origin (the undo-ownership repair's `UNDO_REPAIR_ORIGIN`
132
- * Symbol and the actor-dictionary's `ATTRIBUTION_ORIGIN` are the existing
133
- * examples) or inside a non-local transaction; either channel is
134
- * sufficient and both are free of the local undo stack.
135
- *
136
- * History is lazy: it attaches at `sync()`, or on first `history` access
137
- * once the doc is initialized — it NEVER initializes content itself, so
138
- * bootstrap/hydration writes all predate capture and are naturally
139
- * excluded. Stacks are unbounded (the engine prunes nothing); prune
140
- * explicitly via {@link clearHistory} / `history.clear()`, and tune the
141
- * merge window via `history.captureTimeout`.
142
- */
143
1
  import { DEV } from 'esm-env';
144
2
  import { Y } from './engine.js';
145
3
  import { bindAttribution } from './attribution/index.js';
@@ -313,7 +171,8 @@ export class EdytorDocument {
313
171
  _capability = {
314
172
  roles: new Map(),
315
173
  rendersContent: new Map(),
316
- defaultChild: new Map()
174
+ defaultChild: new Map(),
175
+ marks: new Map()
317
176
  };
318
177
  _defaultType;
319
178
  _historyOptions;
@@ -337,6 +196,7 @@ export class EdytorDocument {
337
196
  defaultType: this._defaultType,
338
197
  defaultChildOf: (type) => this._capability.defaultChild.get(type),
339
198
  rendersContent: (type) => this.rendersContent(type),
199
+ markEdge: (mark) => this._capability.marks.get(mark),
340
200
  // U1: the facade's block-attribution ops read the actor lazily —
341
201
  // `this.actor` is assigned below, after facade construction.
342
202
  actor: () => this.actor,
@@ -455,12 +315,13 @@ export class EdytorDocument {
455
315
  * copies — mutation is not supported; contribute via {@link adoptSemantics}.
456
316
  */
457
317
  get semantics() {
458
- const { roles, rendersContent, defaultChild } = this._capability;
318
+ const { roles, rendersContent, defaultChild, marks } = this._capability;
459
319
  return {
460
320
  defaultType: this._defaultType,
461
321
  roles: new Map(roles),
462
322
  rendersContent: new Map(rendersContent),
463
- defaultChild: new Map(defaultChild)
323
+ defaultChild: new Map(defaultChild),
324
+ marks: new Map(marks)
464
325
  };
465
326
  }
466
327
  /**
@@ -498,7 +359,11 @@ export class EdytorDocument {
498
359
  const incoming = [
499
360
  ['roles', Object.entries(config.roles ?? {}).map(([t, r]) => [t, normalizeRole(r)])],
500
361
  ['rendersContent', Object.entries(config.rendersContent ?? {})],
501
- ['defaultChild', Object.entries(config.defaultChild ?? {})]
362
+ ['defaultChild', Object.entries(config.defaultChild ?? {})],
363
+ [
364
+ 'marks',
365
+ Object.entries(config.marks ?? {}).map(([mark, m]) => [mark, m?.edge ?? 'inclusive'])
366
+ ]
502
367
  ];
503
368
  // Pass 1 — validate EVERYTHING before mutating: a conflicting entry
504
369
  // late in a table must not leave earlier entries half-adopted.