edytor 0.1.0-next.21 → 0.1.0-next.23

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 (69) hide show
  1. package/README.md +1 -1
  2. package/dist/cloudflare/DocumentRoom.d.ts +302 -10
  3. package/dist/cloudflare/DocumentRoom.js +965 -88
  4. package/dist/cloudflare/index.d.ts +1 -1
  5. package/dist/cloudflare/index.js +1 -1
  6. package/dist/cloudflare/routeDocumentSocket.js +30 -6
  7. package/dist/collaboration/awarenessSelection.d.ts +7 -1
  8. package/dist/collaboration/awarenessSelection.js +7 -1
  9. package/dist/collaboration/index.d.ts +2 -2
  10. package/dist/collaboration/index.js +2 -2
  11. package/dist/collaboration/providers.d.ts +4 -0
  12. package/dist/collaboration/providers.js +4 -0
  13. package/dist/components/Edytor.svelte +14 -2
  14. package/dist/components/Edytor.svelte.d.ts +8 -1
  15. package/dist/crdt/admission.d.ts +6 -1
  16. package/dist/crdt/admission.js +9 -2
  17. package/dist/crdt/data.d.ts +3 -1
  18. package/dist/crdt/data.js +42 -32
  19. package/dist/crdt/document.d.ts +31 -8
  20. package/dist/crdt/document.js +53 -18
  21. package/dist/crdt/edytor-doc.d.ts +31 -3
  22. package/dist/crdt/edytor-doc.js +131 -14
  23. package/dist/crdt/engine.d.ts +6 -0
  24. package/dist/crdt/engine.js +7 -0
  25. package/dist/crdt/flow.d.ts +2 -0
  26. package/dist/crdt/flow.js +2 -2
  27. package/dist/crdt/index.d.ts +20 -9
  28. package/dist/crdt/index.js +3 -3
  29. package/dist/crdt/migration/migrate.js +9 -3
  30. package/dist/crdt/placement/model.d.ts +9 -1
  31. package/dist/crdt/placement/model.js +29 -10
  32. package/dist/crdt/placement/rank.d.ts +20 -0
  33. package/dist/crdt/placement/rank.js +79 -0
  34. package/dist/crdt/protocols/envelope.d.ts +20 -1
  35. package/dist/crdt/protocols/envelope.js +14 -2
  36. package/dist/crdt/providers/container.d.ts +15 -0
  37. package/dist/crdt/providers/container.js +17 -3
  38. package/dist/crdt/providers/index.d.ts +21 -1
  39. package/dist/crdt/providers/index.js +86 -3
  40. package/dist/crdt/providers/indexeddb.js +60 -40
  41. package/dist/crdt/providers/room.d.ts +18 -2
  42. package/dist/crdt/providers/room.js +29 -5
  43. package/dist/crdt/providers/websocket.d.ts +9 -1
  44. package/dist/crdt/providers/websocket.js +21 -6
  45. package/dist/crdt/storage.d.ts +21 -0
  46. package/dist/crdt/storage.js +37 -0
  47. package/dist/crdt/structs.d.ts +17 -0
  48. package/dist/crdt/structs.js +41 -0
  49. package/dist/crdt/text/model.d.ts +9 -1
  50. package/dist/crdt/text/model.js +24 -14
  51. package/dist/crdt/text/rows.d.ts +94 -0
  52. package/dist/crdt/text/rows.js +193 -0
  53. package/dist/crdt/text/runs.d.ts +30 -10
  54. package/dist/crdt/text/runs.js +1235 -264
  55. package/dist/edytor.svelte.d.ts +3 -1
  56. package/dist/edytor.svelte.js +9 -6
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/plugins/image/ImageEmpty.svelte +19 -6
  60. package/dist/plugins/image/ImagePlugin.svelte +9 -3
  61. package/dist/plugins/image/ImagePlugin.svelte.d.ts +2 -2
  62. package/dist/plugins/image/image.d.ts +16 -0
  63. package/dist/plugins/image/image.js +19 -0
  64. package/dist/plugins/index.d.ts +1 -1
  65. package/dist/plugins/index.js +1 -1
  66. package/dist/plugins.d.ts +11 -0
  67. package/dist/session/commands.d.ts +1 -0
  68. package/dist/session/commands.js +4 -1
  69. package/package.json +1 -1
@@ -143,7 +143,7 @@
143
143
  import { DEV } from 'esm-env';
144
144
  import { Y } from './engine.js';
145
145
  import { bindAttribution } from './attribution/index.js';
146
- import { bindEdytorDoc, isInitialized, lineageDepthOf } from './edytor-doc.js';
146
+ import { bindEdytorDoc, DEFAULT_HISTORY_LIMIT, isInitialized, lineageDepthOf } from './edytor-doc.js';
147
147
  import { assertAdmission, assertSchema, bindAdmission, checkSchema } from './admission.js';
148
148
  import { Awareness } from './protocols/awareness.js';
149
149
  import { callEach } from './protocols/observable.js';
@@ -199,12 +199,23 @@ export class DocumentDestroyedError extends Error {
199
199
  * does: from the socket's open, so a slow dial never counts).
200
200
  */
201
201
  export const DEFAULT_READINESS_BOUND = 1000;
202
- const normalizeRole = (role) => ({
203
- void: role?.void === true,
204
- island: role?.island === true,
205
- lines: role?.lines === true,
206
- ...(role?.layout === true && { layout: true })
207
- });
202
+ const normalizeRole = (role) => {
203
+ const atomic = [
204
+ ...new Map((role?.atomic ?? []).map((p) => {
205
+ const path = typeof p === 'string' ? [p] : [...p];
206
+ return [JSON.stringify(path), path];
207
+ }))
208
+ ]
209
+ .sort(([a], [b]) => (a < b ? -1 : 1))
210
+ .map(([, path]) => path);
211
+ return {
212
+ void: role?.void === true,
213
+ island: role?.island === true,
214
+ lines: role?.lines === true,
215
+ ...(role?.layout === true && { layout: true }),
216
+ ...(atomic.length > 0 && { atomic })
217
+ };
218
+ };
208
219
  const anonymousActor = () => ({
209
220
  id: `anon-${crypto.randomUUID()}`
210
221
  });
@@ -307,6 +318,8 @@ export class EdytorDocument {
307
318
  _defaultType;
308
319
  _historyOptions;
309
320
  _lineageDepth;
321
+ /** {@link DocumentOptions.requireHydration}: an empty document waits for a provider's `synced`. */
322
+ requireHydration;
310
323
  /** Attached providers keyed by transport target (O75): one per target. */
311
324
  _providers = new Map();
312
325
  _pendingSyncs = 0;
@@ -336,6 +349,7 @@ export class EdytorDocument {
336
349
  this._ownsAwareness = init.awareness === undefined;
337
350
  this._historyOptions = init.history;
338
351
  this._lineageDepth = init.lineage?.depth;
352
+ this.requireHydration = init.requireHydration === true;
339
353
  this.actor = init.actor ?? anonymousActor();
340
354
  // Durable actor identity is separate from the presence profile: the
341
355
  // `actor` field carries the stable id (U5/U6 stub seam), `user`
@@ -418,6 +432,14 @@ export class EdytorDocument {
418
432
  get historyCaptureTimeout() {
419
433
  return this._historyOptions?.captureTimeout;
420
434
  }
435
+ /**
436
+ * The undo steps this document's history keeps (P6): the configured
437
+ * `history.limit`, else {@link DEFAULT_HISTORY_LIMIT}. The `attachDocument`
438
+ * dedupe check reads it (a reattach naming another limit is a conflict).
439
+ */
440
+ get historyLimit() {
441
+ return this._historyOptions?.limit ?? DEFAULT_HISTORY_LIMIT;
442
+ }
421
443
  /**
422
444
  * The lineage ring depth this document was configured with — read by
423
445
  * the `attachDocument` dedupe compatibility check (a reattach naming
@@ -657,17 +679,17 @@ export class EdytorDocument {
657
679
  this._history = this.facade.createUndoManager({
658
680
  trackedOrigins: this._trackedOrigins,
659
681
  captureTimeout: this._historyOptions?.captureTimeout,
682
+ limit: this.historyLimit,
660
683
  captureTransaction: (transaction) => transaction.local !== false
661
684
  });
662
685
  return this._history;
663
686
  };
664
687
  /**
665
- * Prune the undo/redo stacks — the document's retention surface. The
666
- * engine keeps unbounded stacks: nothing is pruned implicitly (the
667
- * `captureTimeout` merge window only fuses adjacent commits into one
668
- * step). Call this to bound memory on long-lived documents, e.g. after
669
- * a save checkpoint. No-op while history has not attached (there is
670
- * nothing to clear — it does NOT force attach or throw on pending).
688
+ * Drop the undo/redo stacks. The undo stack already keeps only its
689
+ * newest `history.limit` steps (P6); call this where undo no longer makes
690
+ * sense, e.g. after a save checkpoint. No-op while history has not
691
+ * attached (there is nothing to clear — it does NOT force attach or throw
692
+ * on pending).
671
693
  */
672
694
  clearHistory = () => {
673
695
  if (this._destroyed) {
@@ -740,9 +762,12 @@ export class EdytorDocument {
740
762
  * decides again when it turns writable (the refusal propagates to the
741
763
  * reporting provider only).
742
764
  */
743
- _decide = (value, report = false) => {
765
+ _decide = (value, report = false, synced = false) => {
744
766
  if (this._destroyed || this.ready)
745
767
  return;
768
+ // H12: an empty document waits for a provider that synced.
769
+ if (this.requireHydration && !synced && !isInitialized(this.doc))
770
+ return;
746
771
  const waiting = this._pendingSyncs > 0 || this._refusals.size > 0;
747
772
  if (waiting && !isInitialized(this.doc))
748
773
  return;
@@ -824,7 +849,7 @@ export class EdytorDocument {
824
849
  synced: () => {
825
850
  settle();
826
851
  this._refusals.delete(target);
827
- this._decide(opts.value, true);
852
+ this._decide(opts.value, true, true);
828
853
  },
829
854
  failed,
830
855
  armBound: () => {
@@ -1017,9 +1042,16 @@ export const bindDocument = (Y) => {
1017
1042
  options.history.captureTimeout !== existing.historyCaptureTimeout) {
1018
1043
  throw new SemanticConflictError('history.captureTimeout', existing.historyCaptureTimeout, options.history.captureTimeout);
1019
1044
  }
1045
+ if (options.history?.limit !== undefined && options.history.limit !== existing.historyLimit) {
1046
+ throw new SemanticConflictError('history.limit', existing.historyLimit, options.history.limit);
1047
+ }
1020
1048
  if (options.lineage?.depth !== undefined && options.lineage.depth !== existing.lineageDepth) {
1021
1049
  throw new SemanticConflictError('lineage.depth', existing.lineageDepth, options.lineage.depth);
1022
1050
  }
1051
+ if (options.requireHydration !== undefined &&
1052
+ options.requireHydration !== existing.requireHydration) {
1053
+ throw new SemanticConflictError('requireHydration', existing.requireHydration, options.requireHydration);
1054
+ }
1023
1055
  if (options.semantics !== undefined) {
1024
1056
  existing.adoptSemantics(options.semantics);
1025
1057
  }
@@ -1045,7 +1077,8 @@ export const bindDocument = (Y) => {
1045
1077
  awareness: options.awareness,
1046
1078
  semantics: options.semantics,
1047
1079
  history: options.history,
1048
- lineage: options.lineage
1080
+ lineage: options.lineage,
1081
+ requireHydration: options.requireHydration
1049
1082
  });
1050
1083
  attached.set(document.doc, document);
1051
1084
  if (options.value !== undefined) {
@@ -1082,7 +1115,8 @@ export const bindDocument = (Y) => {
1082
1115
  awareness: options.awareness,
1083
1116
  semantics: options.semantics,
1084
1117
  history: options.history,
1085
- lineage: options.lineage
1118
+ lineage: options.lineage,
1119
+ requireHydration: options.requireHydration
1086
1120
  });
1087
1121
  attached.set(doc, document);
1088
1122
  try {
@@ -1144,7 +1178,8 @@ export const bindDocument = (Y) => {
1144
1178
  awareness: options.awareness,
1145
1179
  semantics: options.semantics,
1146
1180
  history: options.history,
1147
- lineage: options.lineage
1181
+ lineage: options.lineage,
1182
+ requireHydration: options.requireHydration
1148
1183
  });
1149
1184
  attached.set(doc, document);
1150
1185
  return document;
@@ -98,6 +98,8 @@ export declare const assertUsableDoc: (doc: EngineDoc) => void;
98
98
  * counterpart of `DocumentDestroyedError` — kept in this module because a
99
99
  * bare `bindEdytorDoc` facade has no document layer above it.
100
100
  */
101
+ /** The undo steps a document's history keeps by default (P6): older ones are released. */
102
+ export declare const DEFAULT_HISTORY_LIMIT = 200;
101
103
  export declare class EdytorDocDisposedError extends Error {
102
104
  constructor(service?: string);
103
105
  }
@@ -120,6 +122,14 @@ export type BlockRole = {
120
122
  * a `defaultChild`.
121
123
  */
122
124
  layout?: boolean;
125
+ /**
126
+ * Data paths written as one leaf (`data.atomic`, H8): a top-level key, or
127
+ * an array of keys for a nested one (`['link', ['media', 'source']]`). An
128
+ * assignment there, or anywhere under it, writes the whole value as one
129
+ * last-writer-wins leaf, so two concurrent assignments never merge into a
130
+ * value neither wrote: one wins whole.
131
+ */
132
+ atomic?: readonly (string | readonly string[])[];
123
133
  };
124
134
  /** Island-sealing policy for a walk in document order (R5; see `next`). */
125
135
  export type OrderPolicy = {
@@ -495,7 +505,7 @@ export declare const bindEdytorDoc: (Y: EngineApi) => {
495
505
  isSelfOrDescendant: (placements: Map<BlockId, import("./placement/model.js").ResolvedPlacement>, own: import("./text/model.js").Ownership, id: BlockId, maybeAncestor: BlockId) => boolean;
496
506
  ranksAt: (siblings: readonly {
497
507
  rank: string;
498
- }[], index: number, count: number, clientId: number, rand?: () => number) => string[];
508
+ }[], index: number, count: number, clientId: number, rand?: () => number, run?: boolean) => string[];
499
509
  writePlacement: (doc: EngineDoc, node: EngineNode, p: BlockId | null, r: string) => void;
500
510
  withdrawOnUndo: (doc: EngineDoc) => (item: import("./engine-api.js").EngineItemRef, stackItem: {
501
511
  inserts: {
@@ -617,7 +627,17 @@ export declare const bindEdytorDoc: (Y: EngineApi) => {
617
627
  checkSchema: () => SchemaProblem | null;
618
628
  assertSchema: () => void;
619
629
  dispose: () => void;
620
- createUndoManager: (opts?: ConstructorParameters<EngineApi["UndoManager"]>[1]) => YUndoManager;
630
+ createUndoManager: (options?: ConstructorParameters<EngineApi["UndoManager"]>[1] & {
631
+ limit?: number;
632
+ }) => YUndoManager;
633
+ /**
634
+ * Drop every step of a history `createUndoManager` made (undo and
635
+ * redo) and release what they kept for their undo, as the history
636
+ * limit does (P6): the engine collects that content now, the doc's
637
+ * `gcFilter` still deciding. The room's validation history (H2) runs
638
+ * it after each frame.
639
+ */
640
+ releaseHistory: (um: YUndoManager) => void;
621
641
  project: () => import("./index.js").ProjectedDoc;
622
642
  toJSON: () => JSONDoc;
623
643
  blockJSON: (id: string) => JSONBlock;
@@ -696,6 +716,14 @@ export declare const bindEdytorDoc: (Y: EngineApi) => {
696
716
  })[];
697
717
  onChange: (cb: (change: DocChange) => void) => (() => void);
698
718
  };
719
+ /**
720
+ * Keep, on `doc`, the text a replica may have to copy again (P11): its
721
+ * `gcFilter` then spares a deleted copy's content, as every facade's
722
+ * history does. A doc that only relays updates (the room) calls it
723
+ * before applying anything, so what it collects and encodes matches
724
+ * what an editing replica keeps.
725
+ */
726
+ keepCopies: (doc: EngineDoc) => void;
699
727
  /** The bound engine layers (same instances the facades use). */
700
728
  model: {
701
729
  REGISTRY_KEY: string;
@@ -712,7 +740,7 @@ export declare const bindEdytorDoc: (Y: EngineApi) => {
712
740
  isSelfOrDescendant: (placements: Map<BlockId, import("./placement/model.js").ResolvedPlacement>, own: import("./text/model.js").Ownership, id: BlockId, maybeAncestor: BlockId) => boolean;
713
741
  ranksAt: (siblings: readonly {
714
742
  rank: string;
715
- }[], index: number, count: number, clientId: number, rand?: () => number) => string[];
743
+ }[], index: number, count: number, clientId: number, rand?: () => number, run?: boolean) => string[];
716
744
  writePlacement: (doc: EngineDoc, node: EngineNode, p: BlockId | null, r: string) => void;
717
745
  withdrawOnUndo: (doc: EngineDoc) => (item: import("./engine-api.js").EngineItemRef, stackItem: {
718
746
  inserts: {
@@ -118,7 +118,7 @@ import { bindBlockAttribution, blockAttributionOf } from './attribution/block.js
118
118
  import { isLegacyDoc } from './migration/legacy-schema.js';
119
119
  import { rangeDeleteOps } from './rangeDelete.js';
120
120
  import { flowOps } from './flow.js';
121
- import { dataLeaves, isObject, itemIds, patchWrites, readData, writeLeaves } from './data.js';
121
+ import { dataLeaves, isObject, itemIds, leafKey, patchWrites, readData, writeLeaves } from './data.js';
122
122
  import { id as newId } from '../utils.js';
123
123
  import { cloneJsonSafe, jsonBlockToSpec, jsonEquals, sanitizeSpec, sanitizeWireJson, sanitizeWireString } from '../utils/json.js';
124
124
  // ── schema manifest ─────────────────────────────────────────────────────
@@ -249,6 +249,10 @@ export const assertUsableDoc = (doc) => {
249
249
  * counterpart of `DocumentDestroyedError` — kept in this module because a
250
250
  * bare `bindEdytorDoc` facade has no document layer above it.
251
251
  */
252
+ /** The undo steps a document's history keeps by default (P6): older ones are released. */
253
+ export const DEFAULT_HISTORY_LIMIT = 200;
254
+ /** The origin of the transaction that releases a dropped history step's content. */
255
+ const HISTORY_TRIM = Symbol('edytor.history.trim');
252
256
  export class EdytorDocDisposedError extends Error {
253
257
  constructor(service) {
254
258
  super(`EdytorDoc${service ? `.${service}` : ''}: facade is disposed.`);
@@ -341,6 +345,8 @@ export const bindEdytorDoc = (Y) => {
341
345
  const M = bindModel(Y);
342
346
  const T = bindText(Y);
343
347
  const D = bindDeletes(Y);
348
+ /** Each history's release of all its steps (`releaseHistory`). */
349
+ const releasers = new WeakMap();
344
350
  // U1 — compact per-block attribution writes (`attribution/block.ts`).
345
351
  // One bound instance per engine binding; its suppression memory is
346
352
  // per-doc (WeakMap-keyed), so facades on the same doc share it.
@@ -1071,10 +1077,11 @@ export const bindEdytorDoc = (Y) => {
1071
1077
  node.setAttr(key, value);
1072
1078
  }
1073
1079
  };
1074
- const createUndoManager = (opts = {}) => {
1080
+ const createUndoManager = (options = {}) => {
1075
1081
  if (disposed) {
1076
1082
  throw new EdytorDocDisposedError('createUndoManager');
1077
1083
  }
1084
+ const { limit = DEFAULT_HISTORY_LIMIT, ...opts } = options;
1078
1085
  write(() => {
1079
1086
  if (!isInitialized(doc))
1080
1087
  init(doc);
@@ -1102,6 +1109,49 @@ export const bindEdytorDoc = (Y) => {
1102
1109
  };
1103
1110
  um.on('stack-item-added', skipOwnText);
1104
1111
  um.on('stack-item-updated', skipOwnText);
1112
+ // P6: the undo stack keeps its newest `limit` steps. A step that
1113
+ // falls off releases what it kept for its undo (its deleted items),
1114
+ // and the engine collects their content now, as it would have at
1115
+ // the delete without a history (the doc's `gcFilter` still decides:
1116
+ // a text copy another replica may have to copy again stays, P11).
1117
+ // An item is released only once every step that deleted part of it
1118
+ // fell off (the engine merges deleted items across steps): never
1119
+ // split, so the store keeps its merged items. Only the items
1120
+ // themselves are released: a kept container's flag may guard a
1121
+ // newer step's items inside it.
1122
+ const released = Y.createIdSet();
1123
+ const covered = (st) => {
1124
+ const end = st.id.clock + st.length;
1125
+ return (released.clients.get(st.id.client)?.getIds() ?? []).some((r) => r.clock <= st.id.clock && end <= r.clock + r.len);
1126
+ };
1127
+ const release = (dropped) => {
1128
+ if (dropped.length === 0)
1129
+ return;
1130
+ for (const step of dropped)
1131
+ Y.insertIntoIdSet(released, step.deletes);
1132
+ const gc = doc.gc;
1133
+ const keepIt = doc.gcFilter;
1134
+ doc.transact((tr) => {
1135
+ for (const step of dropped)
1136
+ walkIdSetStructs(Y, doc, step.deletes, (st) => {
1137
+ const it = st;
1138
+ if (it.content === undefined || it.keep !== true || !covered(it))
1139
+ return;
1140
+ it.keep = false;
1141
+ if (gc && it.deleted && keepIt(it))
1142
+ it.gc(tr, false);
1143
+ });
1144
+ }, HISTORY_TRIM);
1145
+ };
1146
+ const trim = ({ type }) => {
1147
+ const over = um.undoStack.length - limit;
1148
+ if (type !== 'undo' || !(over > 0))
1149
+ return;
1150
+ release(um.undoStack.splice(0, over));
1151
+ };
1152
+ um.on('stack-item-added', trim);
1153
+ // `releaseHistory(um)`: every step dropped and released by the same rule.
1154
+ releasers.set(um, () => release([...um.undoStack.splice(0), ...um.redoStack.splice(0)]));
1105
1155
  // Lineage for undo/redo (O19, F4): the replay displaces the state
1106
1156
  // every block the popped stack item touches, so each one's subtree
1107
1157
  // is captured (`force`: lost whoever owns `l`) from the history
@@ -1226,7 +1276,7 @@ export const bindEdytorDoc = (Y) => {
1226
1276
  const i = T.resolveAnchor(doc, text, anchor.a);
1227
1277
  if (i === null)
1228
1278
  return null;
1229
- const s = own.streamsIn(anchor.b).find((x) => x.start <= i && i <= x.end);
1279
+ const s = own.streamAt(anchor.b, i);
1230
1280
  const owner = s === undefined ? DEAD : own.ownerOf(s.block);
1231
1281
  if (typeof owner !== 'string' || !isLiveIn(view(), owner))
1232
1282
  return null;
@@ -1270,11 +1320,30 @@ export const bindEdytorDoc = (Y) => {
1270
1320
  });
1271
1321
  /** This client's next clock: what its source ranks are tied by (`sourceRank`, DW-05). */
1272
1322
  const clock = () => doc.store?.getClock(doc.clientID) ?? 0;
1273
- /** `count` ranks at `index` among `parent`'s children, the moving `exclude` left out. */
1274
- const ranksFor = (parent, index, count, exclude = []) => {
1275
- const sibs = (view().kids.get(parent) ?? []).filter((k) => !exclude.includes(k.id));
1276
- const at = Math.max(0, Math.min(index, sibs.length));
1277
- return M.ranksAt(sibs, at, count, doc.clientID, randOf(doc));
1323
+ /**
1324
+ * `count` ranks at `index` among `parent`'s children, the moving
1325
+ * `exclude` left out. `run`: new blocks, which extend this client's run
1326
+ * after a block it ranked (H1, `order.insert.run`); a move never does
1327
+ * (the rank-growth guard).
1328
+ */
1329
+ const ranksFor = (parent, index, count, exclude = [], run = false) => {
1330
+ const v = view();
1331
+ const all = v.kids.get(parent) ?? [];
1332
+ // The moving blocks' own slots left out: index → the list's index past them.
1333
+ const gone = exclude
1334
+ .map((id) => M.positionInView(v, id))
1335
+ .filter((p) => p !== null && p.parent === parent)
1336
+ .map((p) => p.index)
1337
+ .sort((a, b) => a - b);
1338
+ const raw = (i) => {
1339
+ for (const g of gone)
1340
+ if (g <= i)
1341
+ i++;
1342
+ return i;
1343
+ };
1344
+ const at = Math.max(0, Math.min(index, all.length - gone.length));
1345
+ const pair = [at > 0 ? all[raw(at - 1)] : undefined, all[raw(at)]];
1346
+ return M.ranksAt(pair, 1, count, doc.clientID, randOf(doc), run);
1278
1347
  };
1279
1348
  /**
1280
1349
  * Move `ids` to `ranks` under `parent` (`index`: the slot hooks see).
@@ -1380,7 +1449,12 @@ export const bindEdytorDoc = (Y) => {
1380
1449
  const dataSteps = (target, patches) => {
1381
1450
  const [id, inlineId] = typeof target === 'object' && target ? [target.block, target.atom] : [target ?? undefined];
1382
1451
  const node = dataNode(id, inlineId);
1383
- const leaves = node ? patchWrites(node, patches, doc.clientID, randOf(doc)) : [];
1452
+ // A block kind's atomic paths are written as one leaf each (`data.atomic`, H8).
1453
+ const type = inlineId === undefined && id !== undefined ? blockTypeOf(id) : undefined;
1454
+ const atomic = (type === undefined ? undefined : roleOf(type)?.atomic) ?? [];
1455
+ const leaves = node
1456
+ ? patchWrites(node, patches, doc.clientID, randOf(doc), atomic.map((p) => leafKey(typeof p === 'string' ? [p] : p)))
1457
+ : [];
1384
1458
  if (leaves === null)
1385
1459
  return null; // a patch fits no value there
1386
1460
  if (leaves.length === 0)
@@ -1400,6 +1474,26 @@ export const bindEdytorDoc = (Y) => {
1400
1474
  const replaceData = (data) => [
1401
1475
  { path: [], value: sanitizeWireJson(data) }
1402
1476
  ];
1477
+ /**
1478
+ * `data`'s leaves as sets over `current` (`data.retype.keep`, H4): a
1479
+ * plain object's keys, recursively where `current` holds an object
1480
+ * there too; anything else (an array, a primitive, an empty object, an
1481
+ * object where `current` holds none) as one value at its path. Nothing
1482
+ * else under the root is touched.
1483
+ */
1484
+ const leafSets = (data, current) => {
1485
+ const out = [];
1486
+ const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
1487
+ const walk = (v, at, path) => {
1488
+ if (plainObject(v) && Object.keys(v).length > 0 && (path.length === 0 || plainObject(at)))
1489
+ for (const [k, x] of Object.entries(v))
1490
+ walk(x, plainObject(at) ? at[k] : undefined, [...path, k]);
1491
+ else if (path.length > 0)
1492
+ out.push({ path, value: v });
1493
+ };
1494
+ walk(sanitizeWireJson(data), current, []);
1495
+ return out;
1496
+ };
1403
1497
  /**
1404
1498
  * The kind `kid` shows once it leaves `from` for a slot under `parent`
1405
1499
  * — the island and container rules, one answer:
@@ -1602,7 +1696,7 @@ export const bindEdytorDoc = (Y) => {
1602
1696
  return plan([], []);
1603
1697
  if ((parent !== null && !live(parent)) || M.collides(doc, clean))
1604
1698
  return REFUSED;
1605
- const ranks = ranksFor(parent, dest.index, clean.length);
1699
+ const ranks = ranksFor(parent, dest.index, clean.length, [], true);
1606
1700
  return plan(clean.map((s) => s.id), [{ op: 'insertBlocks', parent, index: dest.index, specs: clean, ranks }]);
1607
1701
  };
1608
1702
  /**
@@ -1700,7 +1794,7 @@ export const bindEdytorDoc = (Y) => {
1700
1794
  const last = ids.at(-1);
1701
1795
  const { parent, index } = positionOf(levels.at(-1) ?? last);
1702
1796
  if (levels.length === 0) {
1703
- const ranks = ranksFor(parent, index + 1, specs.length);
1797
+ const ranks = ranksFor(parent, index + 1, specs.length, [], true);
1704
1798
  return plan(placed, [...insert(parent, index + 1, specs, ranks), ...retype]);
1705
1799
  }
1706
1800
  // Bottom up: what stays before the split at each level (the level
@@ -2265,8 +2359,11 @@ export const bindEdytorDoc = (Y) => {
2265
2359
  return steps ? plan(t === null ? [] : [typeof t === 'string' ? t : t.block], steps) : REFUSED;
2266
2360
  };
2267
2361
  /**
2268
- * Baseline `setBlock`: `type`/`data` update the block in place;
2269
- * `content`/`children` REPLACE wholesale — explicit replacement is a
2362
+ * Baseline `setBlock`: `type` updates the block in place and `data`
2363
+ * sets the leaves it names, removing none (`data.retype.keep`, H4: a
2364
+ * retype keeps the block's properties, as Notion's Turn into does;
2365
+ * `setBlockData` replaces them); `content`/`children` REPLACE
2366
+ * wholesale — explicit replacement is a
2270
2367
  * new-identity operation; a retype to a void kind without `children`
2271
2368
  * unnests the current ones (`retypeSteps`). All-or-nothing (D-12): children
2272
2369
  * for a block that is `void` after the write, or a replacement id that
@@ -2287,13 +2384,16 @@ export const bindEdytorDoc = (Y) => {
2287
2384
  return REFUSED;
2288
2385
  if (children !== undefined && M.collides(doc, children))
2289
2386
  return refused('id-collision');
2387
+ const data = value.data === undefined ? [] : dataSteps(id, leafSets(value.data, blockDataOf(id)));
2388
+ if (data === null)
2389
+ return REFUSED;
2290
2390
  const writes = [
2291
2391
  ...(type === undefined
2292
2392
  ? []
2293
2393
  : children === undefined
2294
2394
  ? retypeSteps(id, type)
2295
2395
  : attr(id, TYPE, type)),
2296
- ...((value.data !== undefined && dataSteps(id, replaceData(value.data))) || [])
2396
+ ...data
2297
2397
  ];
2298
2398
  if (content !== undefined) {
2299
2399
  const length = displayLength(id);
@@ -2451,6 +2551,7 @@ export const bindEdytorDoc = (Y) => {
2451
2551
  insideItem: (id) => insideItem(id),
2452
2552
  tailOf: (id) => ({ type: kindToCopy(id), data: blockDataOf(id) }),
2453
2553
  ranksFor,
2554
+ insertRanks: (parent, index, count) => ranksFor(parent, index, count, [], true),
2454
2555
  pieceRanks,
2455
2556
  redata: (id, data) => dataSteps(id, replaceData(data)) ?? [],
2456
2557
  deleteBlocks: (ids, filled) => deleteBlocks(ids, false, filled),
@@ -2559,6 +2660,14 @@ export const bindEdytorDoc = (Y) => {
2559
2660
  assertSchema: () => assertSchema(doc),
2560
2661
  dispose,
2561
2662
  createUndoManager,
2663
+ /**
2664
+ * Drop every step of a history `createUndoManager` made (undo and
2665
+ * redo) and release what they kept for their undo, as the history
2666
+ * limit does (P6): the engine collects that content now, the doc's
2667
+ * `gcFilter` still deciding. The room's validation history (H2) runs
2668
+ * it after each frame.
2669
+ */
2670
+ releaseHistory: (um) => releasers.get(um)?.(),
2562
2671
  // reads — every id argument normalizes at ingress (O1)
2563
2672
  project: () => M.project(doc),
2564
2673
  toJSON,
@@ -2679,6 +2788,14 @@ export const bindEdytorDoc = (Y) => {
2679
2788
  SchemaMismatchError,
2680
2789
  /** Attach the per-doc facade. */
2681
2790
  create,
2791
+ /**
2792
+ * Keep, on `doc`, the text a replica may have to copy again (P11): its
2793
+ * `gcFilter` then spares a deleted copy's content, as every facade's
2794
+ * history does. A doc that only relays updates (the room) calls it
2795
+ * before applying anything, so what it collects and encodes matches
2796
+ * what an editing replica keeps.
2797
+ */
2798
+ keepCopies: (doc) => void D.scope(doc),
2682
2799
  /** The bound engine layers (same instances the facades use). */
2683
2800
  model: M,
2684
2801
  text: T,
@@ -17,6 +17,12 @@ export type EngineSymbol =
17
17
  | 'UpdateEncoderV1'
18
18
  | 'writeIdSet'
19
19
  | 'mergeUpdates'
20
+ | 'applyUpdateV2'
21
+ | 'encodeStateAsUpdateV2'
22
+ | 'mergeUpdatesV2'
23
+ | 'convertUpdateFormatV2ToV1'
24
+ | 'UpdateDecoderV1'
25
+ | 'UpdateEncoderV2'
20
26
  | 'Skip'
21
27
  | 'findIndexSS'
22
28
  | 'createRelativePositionFromTypeIndex'
@@ -30,6 +30,13 @@ export const Y = {
30
30
  UpdateEncoderV1: V.UpdateEncoderV1,
31
31
  writeIdSet: V.writeIdSet,
32
32
  mergeUpdates: V.mergeUpdates,
33
+ // storage in the v2 (columnar) encoding (P5): room rows and the IndexedDB store
34
+ applyUpdateV2: V.applyUpdateV2,
35
+ encodeStateAsUpdateV2: V.encodeStateAsUpdateV2,
36
+ mergeUpdatesV2: V.mergeUpdatesV2,
37
+ convertUpdateFormatV2ToV1: V.convertUpdateFormatV2ToV1,
38
+ UpdateDecoderV1: V.UpdateDecoderV1,
39
+ UpdateEncoderV2: V.UpdateEncoderV2,
33
40
  Skip: V.Skip,
34
41
  findIndexSS: V.findIndexSS,
35
42
  createRelativePositionFromTypeIndex: V.createRelativePositionFromTypeIndex,
@@ -87,6 +87,8 @@ export type FlowContext = RangeDeleteContext & {
87
87
  insideItem: (id: BlockId) => boolean;
88
88
  tailOf: (id: BlockId) => SplitTail;
89
89
  ranksFor: (parent: BlockId | null, index: number, count: number) => string[];
90
+ /** `ranksFor` for new blocks: after a block this client ranked, in its run there (H1). */
91
+ insertRanks: (parent: BlockId | null, index: number, count: number) => string[];
90
92
  /** Ranks for the `count` blocks a split of `id` at `at` puts after it, by that offset. */
91
93
  pieceRanks: (id: BlockId, at: number, count: number) => string[];
92
94
  redata: (id: BlockId, data: Record<string, unknown>) => PlanStep[];
package/dist/crdt/flow.js CHANGED
@@ -190,13 +190,13 @@ export const flowOps = (c) => ({
190
190
  // Nothing before the position and the first line stands apart: the lines go before `B`,
191
191
  // which keeps its text and takes a joining last line, or goes when empty and not needed.
192
192
  if (!joinsHead && o === 0 && !inside) {
193
- place(index, c.ranksFor(parent, index, placed.length));
193
+ place(index, c.insertRanks(parent, index, placed.length));
194
194
  if (joinsTail) {
195
195
  const at = join(last(), 0);
196
196
  // A closed header's taken line's nested lines follow it, the caret ending them.
197
197
  const after = shut ? specs(parent, last().children ?? []) : [];
198
198
  if (after.length) {
199
- const ranks = c.ranksFor(parent, index + 1, after.length);
199
+ const ranks = c.insertRanks(parent, index + 1, after.length);
200
200
  const slot = index + placed.length + 1;
201
201
  writes.push({ op: 'insertBlocks', parent, index: slot, specs: after, ranks });
202
202
  }