edytor 0.1.0-next.21 → 0.1.0-next.22

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.
@@ -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.`);
@@ -1071,10 +1075,11 @@ export const bindEdytorDoc = (Y) => {
1071
1075
  node.setAttr(key, value);
1072
1076
  }
1073
1077
  };
1074
- const createUndoManager = (opts = {}) => {
1078
+ const createUndoManager = (options = {}) => {
1075
1079
  if (disposed) {
1076
1080
  throw new EdytorDocDisposedError('createUndoManager');
1077
1081
  }
1082
+ const { limit = DEFAULT_HISTORY_LIMIT, ...opts } = options;
1078
1083
  write(() => {
1079
1084
  if (!isInitialized(doc))
1080
1085
  init(doc);
@@ -1102,6 +1107,43 @@ export const bindEdytorDoc = (Y) => {
1102
1107
  };
1103
1108
  um.on('stack-item-added', skipOwnText);
1104
1109
  um.on('stack-item-updated', skipOwnText);
1110
+ // P6: the undo stack keeps its newest `limit` steps. A step that
1111
+ // falls off releases what it kept for its undo (its deleted items),
1112
+ // and the engine collects their content now, as it would have at
1113
+ // the delete without a history (the doc's `gcFilter` still decides:
1114
+ // a text copy another replica may have to copy again stays, P11).
1115
+ // An item is released only once every step that deleted part of it
1116
+ // fell off (the engine merges deleted items across steps): never
1117
+ // split, so the store keeps its merged items. Only the items
1118
+ // themselves are released: a kept container's flag may guard a
1119
+ // newer step's items inside it.
1120
+ const released = Y.createIdSet();
1121
+ const covered = (st) => {
1122
+ const end = st.id.clock + st.length;
1123
+ return (released.clients.get(st.id.client)?.getIds() ?? []).some((r) => r.clock <= st.id.clock && end <= r.clock + r.len);
1124
+ };
1125
+ const trim = ({ type }) => {
1126
+ const over = um.undoStack.length - limit;
1127
+ if (type !== 'undo' || !(over > 0))
1128
+ return;
1129
+ const dropped = um.undoStack.splice(0, over);
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
+ um.on('stack-item-added', trim);
1105
1147
  // Lineage for undo/redo (O19, F4): the replay displaces the state
1106
1148
  // every block the popped stack item touches, so each one's subtree
1107
1149
  // is captured (`force`: lost whoever owns `l`) from the history
@@ -1226,7 +1268,7 @@ export const bindEdytorDoc = (Y) => {
1226
1268
  const i = T.resolveAnchor(doc, text, anchor.a);
1227
1269
  if (i === null)
1228
1270
  return null;
1229
- const s = own.streamsIn(anchor.b).find((x) => x.start <= i && i <= x.end);
1271
+ const s = own.streamAt(anchor.b, i);
1230
1272
  const owner = s === undefined ? DEAD : own.ownerOf(s.block);
1231
1273
  if (typeof owner !== 'string' || !isLiveIn(view(), owner))
1232
1274
  return null;
@@ -1270,11 +1312,30 @@ export const bindEdytorDoc = (Y) => {
1270
1312
  });
1271
1313
  /** This client's next clock: what its source ranks are tied by (`sourceRank`, DW-05). */
1272
1314
  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));
1315
+ /**
1316
+ * `count` ranks at `index` among `parent`'s children, the moving
1317
+ * `exclude` left out. `run`: new blocks, which extend this client's run
1318
+ * after a block it ranked (H1, `order.insert.run`); a move never does
1319
+ * (the rank-growth guard).
1320
+ */
1321
+ const ranksFor = (parent, index, count, exclude = [], run = false) => {
1322
+ const v = view();
1323
+ const all = v.kids.get(parent) ?? [];
1324
+ // The moving blocks' own slots left out: index → the list's index past them.
1325
+ const gone = exclude
1326
+ .map((id) => M.positionInView(v, id))
1327
+ .filter((p) => p !== null && p.parent === parent)
1328
+ .map((p) => p.index)
1329
+ .sort((a, b) => a - b);
1330
+ const raw = (i) => {
1331
+ for (const g of gone)
1332
+ if (g <= i)
1333
+ i++;
1334
+ return i;
1335
+ };
1336
+ const at = Math.max(0, Math.min(index, all.length - gone.length));
1337
+ const pair = [at > 0 ? all[raw(at - 1)] : undefined, all[raw(at)]];
1338
+ return M.ranksAt(pair, 1, count, doc.clientID, randOf(doc), run);
1278
1339
  };
1279
1340
  /**
1280
1341
  * Move `ids` to `ranks` under `parent` (`index`: the slot hooks see).
@@ -1380,7 +1441,12 @@ export const bindEdytorDoc = (Y) => {
1380
1441
  const dataSteps = (target, patches) => {
1381
1442
  const [id, inlineId] = typeof target === 'object' && target ? [target.block, target.atom] : [target ?? undefined];
1382
1443
  const node = dataNode(id, inlineId);
1383
- const leaves = node ? patchWrites(node, patches, doc.clientID, randOf(doc)) : [];
1444
+ // A block kind's atomic paths are written as one leaf each (`data.atomic`, H8).
1445
+ const type = inlineId === undefined && id !== undefined ? blockTypeOf(id) : undefined;
1446
+ const atomic = (type === undefined ? undefined : roleOf(type)?.atomic) ?? [];
1447
+ const leaves = node
1448
+ ? patchWrites(node, patches, doc.clientID, randOf(doc), atomic.map((p) => leafKey(typeof p === 'string' ? [p] : p)))
1449
+ : [];
1384
1450
  if (leaves === null)
1385
1451
  return null; // a patch fits no value there
1386
1452
  if (leaves.length === 0)
@@ -1400,6 +1466,26 @@ export const bindEdytorDoc = (Y) => {
1400
1466
  const replaceData = (data) => [
1401
1467
  { path: [], value: sanitizeWireJson(data) }
1402
1468
  ];
1469
+ /**
1470
+ * `data`'s leaves as sets over `current` (`data.retype.keep`, H4): a
1471
+ * plain object's keys, recursively where `current` holds an object
1472
+ * there too; anything else (an array, a primitive, an empty object, an
1473
+ * object where `current` holds none) as one value at its path. Nothing
1474
+ * else under the root is touched.
1475
+ */
1476
+ const leafSets = (data, current) => {
1477
+ const out = [];
1478
+ const plainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
1479
+ const walk = (v, at, path) => {
1480
+ if (plainObject(v) && Object.keys(v).length > 0 && (path.length === 0 || plainObject(at)))
1481
+ for (const [k, x] of Object.entries(v))
1482
+ walk(x, plainObject(at) ? at[k] : undefined, [...path, k]);
1483
+ else if (path.length > 0)
1484
+ out.push({ path, value: v });
1485
+ };
1486
+ walk(sanitizeWireJson(data), current, []);
1487
+ return out;
1488
+ };
1403
1489
  /**
1404
1490
  * The kind `kid` shows once it leaves `from` for a slot under `parent`
1405
1491
  * — the island and container rules, one answer:
@@ -1602,7 +1688,7 @@ export const bindEdytorDoc = (Y) => {
1602
1688
  return plan([], []);
1603
1689
  if ((parent !== null && !live(parent)) || M.collides(doc, clean))
1604
1690
  return REFUSED;
1605
- const ranks = ranksFor(parent, dest.index, clean.length);
1691
+ const ranks = ranksFor(parent, dest.index, clean.length, [], true);
1606
1692
  return plan(clean.map((s) => s.id), [{ op: 'insertBlocks', parent, index: dest.index, specs: clean, ranks }]);
1607
1693
  };
1608
1694
  /**
@@ -1700,7 +1786,7 @@ export const bindEdytorDoc = (Y) => {
1700
1786
  const last = ids.at(-1);
1701
1787
  const { parent, index } = positionOf(levels.at(-1) ?? last);
1702
1788
  if (levels.length === 0) {
1703
- const ranks = ranksFor(parent, index + 1, specs.length);
1789
+ const ranks = ranksFor(parent, index + 1, specs.length, [], true);
1704
1790
  return plan(placed, [...insert(parent, index + 1, specs, ranks), ...retype]);
1705
1791
  }
1706
1792
  // Bottom up: what stays before the split at each level (the level
@@ -2265,8 +2351,11 @@ export const bindEdytorDoc = (Y) => {
2265
2351
  return steps ? plan(t === null ? [] : [typeof t === 'string' ? t : t.block], steps) : REFUSED;
2266
2352
  };
2267
2353
  /**
2268
- * Baseline `setBlock`: `type`/`data` update the block in place;
2269
- * `content`/`children` REPLACE wholesale — explicit replacement is a
2354
+ * Baseline `setBlock`: `type` updates the block in place and `data`
2355
+ * sets the leaves it names, removing none (`data.retype.keep`, H4: a
2356
+ * retype keeps the block's properties, as Notion's Turn into does;
2357
+ * `setBlockData` replaces them); `content`/`children` REPLACE
2358
+ * wholesale — explicit replacement is a
2270
2359
  * new-identity operation; a retype to a void kind without `children`
2271
2360
  * unnests the current ones (`retypeSteps`). All-or-nothing (D-12): children
2272
2361
  * for a block that is `void` after the write, or a replacement id that
@@ -2287,13 +2376,16 @@ export const bindEdytorDoc = (Y) => {
2287
2376
  return REFUSED;
2288
2377
  if (children !== undefined && M.collides(doc, children))
2289
2378
  return refused('id-collision');
2379
+ const data = value.data === undefined ? [] : dataSteps(id, leafSets(value.data, blockDataOf(id)));
2380
+ if (data === null)
2381
+ return REFUSED;
2290
2382
  const writes = [
2291
2383
  ...(type === undefined
2292
2384
  ? []
2293
2385
  : children === undefined
2294
2386
  ? retypeSteps(id, type)
2295
2387
  : attr(id, TYPE, type)),
2296
- ...((value.data !== undefined && dataSteps(id, replaceData(value.data))) || [])
2388
+ ...data
2297
2389
  ];
2298
2390
  if (content !== undefined) {
2299
2391
  const length = displayLength(id);
@@ -2451,6 +2543,7 @@ export const bindEdytorDoc = (Y) => {
2451
2543
  insideItem: (id) => insideItem(id),
2452
2544
  tailOf: (id) => ({ type: kindToCopy(id), data: blockDataOf(id) }),
2453
2545
  ranksFor,
2546
+ insertRanks: (parent, index, count) => ranksFor(parent, index, count, [], true),
2454
2547
  pieceRanks,
2455
2548
  redata: (id, data) => dataSteps(id, replaceData(data)) ?? [],
2456
2549
  deleteBlocks: (ids, filled) => deleteBlocks(ids, false, filled),
@@ -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
  }
@@ -168,7 +168,7 @@ export declare const bindCrdt: (Y: EngineApi) => {
168
168
  isSelfOrDescendant: (placements: Map<import("./index.js").BlockId, import("./placement/model.js").ResolvedPlacement>, own: import("./text/model.js").Ownership, id: import("./index.js").BlockId, maybeAncestor: import("./index.js").BlockId) => boolean;
169
169
  ranksAt: (siblings: readonly {
170
170
  rank: string;
171
- }[], index: number, count: number, clientId: number, rand?: () => number) => string[];
171
+ }[], index: number, count: number, clientId: number, rand?: () => number, run?: boolean) => string[];
172
172
  writePlacement: (doc: import("./engine-api.js").EngineDoc, node: import("./engine-api.js").EngineNode, p: import("./index.js").BlockId | null, r: string) => void;
173
173
  withdrawOnUndo: (doc: import("./engine-api.js").EngineDoc) => (item: import("./engine-api.js").EngineItemRef, stackItem: {
174
174
  inserts: {
@@ -277,7 +277,9 @@ export declare const bindCrdt: (Y: EngineApi) => {
277
277
  checkSchema: () => import("./edytor-doc.js").SchemaProblem | null;
278
278
  assertSchema: () => void;
279
279
  dispose: () => void;
280
- createUndoManager: (opts?: ConstructorParameters<EngineApi["UndoManager"]>[1]) => import("./engine-api.js").YUndoManager;
280
+ createUndoManager: (options?: ConstructorParameters<EngineApi["UndoManager"]>[1] & {
281
+ limit?: number;
282
+ }) => import("./engine-api.js").YUndoManager;
281
283
  project: () => import("./index.js").ProjectedDoc;
282
284
  toJSON: () => import("./index.js").JSONDoc;
283
285
  blockJSON: (id: string) => import("./index.js").JSONBlock;
@@ -360,7 +362,7 @@ export declare const bindCrdt: (Y: EngineApi) => {
360
362
  isSelfOrDescendant: (placements: Map<import("./index.js").BlockId, import("./placement/model.js").ResolvedPlacement>, own: import("./text/model.js").Ownership, id: import("./index.js").BlockId, maybeAncestor: import("./index.js").BlockId) => boolean;
361
363
  ranksAt: (siblings: readonly {
362
364
  rank: string;
363
- }[], index: number, count: number, clientId: number, rand?: () => number) => string[];
365
+ }[], index: number, count: number, clientId: number, rand?: () => number, run?: boolean) => string[];
364
366
  writePlacement: (doc: import("./engine-api.js").EngineDoc, node: import("./engine-api.js").EngineNode, p: import("./index.js").BlockId | null, r: string) => void;
365
367
  withdrawOnUndo: (doc: import("./engine-api.js").EngineDoc) => (item: import("./engine-api.js").EngineItemRef, stackItem: {
366
368
  inserts: {
@@ -627,7 +629,7 @@ export declare const bindCrdt: (Y: EngineApi) => {
627
629
  };
628
630
  export type Crdt = ReturnType<typeof bindCrdt>;
629
631
  export type { EngineApi, EngineDoc, EngineNode, EngineDeepEvent, EngineTransaction, EngineItemRef, YNode, YItem } from './engine-api.js';
630
- export { SCHEMA_VERSION, META_KEY, EdytorDocDisposedError } from './edytor-doc.js';
632
+ export { SCHEMA_VERSION, META_KEY, EdytorDocDisposedError, DEFAULT_HISTORY_LIMIT } from './edytor-doc.js';
631
633
  export { GENERATION, generationWord, frame, PROTOCOL_VERSION, GENERATION_RECORD, GenerationMismatchError, readProtocolVersion, type GenerationRecord } from './protocols/envelope.js';
632
634
  export { messageSync, messageAwareness, messageAuth, messageQueryAwareness, messageSaved, messageChunk, MAX_FRAME_BYTES, chunkFrame, createChunkReader } from './providers/room.js';
633
635
  export { messageYjsSyncStep1, messageYjsSyncStep2, messageYjsUpdate, type SyncProtocol } from './protocols/sync.js';
@@ -155,7 +155,7 @@ export const bindCrdt = (Y) => {
155
155
  // hibernation), the store-before-ack frame (`messageSaved`) and the bounded
156
156
  // catch-up codec (`chunkFrame`/`createChunkReader`, `messageChunk`).
157
157
  // `pnpm check:worker` keeps this graph Worker-safe.
158
- export { SCHEMA_VERSION, META_KEY, EdytorDocDisposedError } from './edytor-doc.js';
158
+ export { SCHEMA_VERSION, META_KEY, EdytorDocDisposedError, DEFAULT_HISTORY_LIMIT } from './edytor-doc.js';
159
159
  export { GENERATION, generationWord, frame, PROTOCOL_VERSION, GENERATION_RECORD, GenerationMismatchError, readProtocolVersion } from './protocols/envelope.js';
160
160
  export { messageSync, messageAwareness, messageAuth, messageQueryAwareness, messageSaved, messageChunk, MAX_FRAME_BYTES, chunkFrame, createChunkReader } from './providers/room.js';
161
161
  export { messageYjsSyncStep1, messageYjsSyncStep2, messageYjsUpdate } from './protocols/sync.js';
@@ -342,6 +342,14 @@ export declare const displayParentOf: (own: DisplayOwnership, pl: ResolvedPlacem
342
342
  * promoted one ({@link displaySlotOf}).
343
343
  */
344
344
  export declare const childrenIndex: (placements: Map<BlockId, ResolvedPlacement>, own: DisplayOwnership) => Map<BlockId | null, ChildSlot[]>;
345
+ /** The order of a children list: by rank, ties by id. */
346
+ export declare const bySlot: (a: {
347
+ id: BlockId;
348
+ rank: string;
349
+ }, b: {
350
+ id: BlockId;
351
+ rank: string;
352
+ }) => number;
345
353
  /**
346
354
  * Document order (O7): ONE pre-order over the visible blocks of a children
347
355
  * index — `ids` in reading order, `at` the position of each id. Every
@@ -376,7 +384,7 @@ export declare const bindModel: (Y: EngineApi) => {
376
384
  isSelfOrDescendant: (placements: Map<BlockId, ResolvedPlacement>, own: Ownership, id: BlockId, maybeAncestor: BlockId) => boolean;
377
385
  ranksAt: (siblings: readonly {
378
386
  rank: string;
379
- }[], index: number, count: number, clientId: number, rand?: () => number) => string[];
387
+ }[], index: number, count: number, clientId: number, rand?: () => number, run?: boolean) => string[];
380
388
  writePlacement: (doc: EngineDoc, node: EngineNode, p: BlockId | null, r: string) => void;
381
389
  withdrawOnUndo: (doc: EngineDoc) => (item: EngineItemRef, stackItem: {
382
390
  inserts: {
@@ -1,4 +1,4 @@
1
- import { decodeRank, encodeRank, rankBetween, RANK_VMIN } from './rank.js';
1
+ import { decodeRank, encodeRank, rankAfter, rankBetween, RANK_VMIN } from './rank.js';
2
2
  import { AT, AT_NODE, BLOCK_NODE, CONTENT, CONTENT_NODE, DEL_PREFIX, hasDeleteMark, ID, CLAIMS, CLAIMS_NODE, INLINE_NODE, isNodeLike, NONCE, REGISTRY_KEY, TYPE, WITHDRAW_PREFIX } from '../schema.js';
3
3
  import { nonceOf, randOf } from '../rand.js';
4
4
  import { bindRuns } from '../text/runs.js';
@@ -409,11 +409,12 @@ export const childrenIndex = (placements, own) => {
409
409
  else
410
410
  index.set(parent, [slot]);
411
411
  }
412
- for (const bucket of index.values()) {
413
- bucket.sort((a, b) => a.rank === b.rank ? a.id.localeCompare(b.id) : a.rank < b.rank ? -1 : 1);
414
- }
412
+ for (const bucket of index.values())
413
+ bucket.sort(bySlot);
415
414
  return index;
416
415
  };
416
+ /** The order of a children list: by rank, ties by id. */
417
+ export const bySlot = (a, b) => (a.rank === b.rank ? a.id.localeCompare(b.id) : a.rank < b.rank ? -1 : 1);
417
418
  export const documentOrder = (kids) => {
418
419
  const ids = [];
419
420
  const at = new Map();
@@ -559,9 +560,10 @@ export const bindModel = (Y) => {
559
560
  * so each new member takes `left` verbatim: it JOINS the tie and the
560
561
  * `(rank, id)` display sort orders the whole group deterministically.
561
562
  * On a valid seam the emitted chain is `rankBetween(left, right)` then
562
- * `rankBetween(prev, right)`.
563
+ * `rankBetween(prev, right)` — with `run`, `rankAfter` (an insert: after a
564
+ * block this client ranked, in its run there, H1).
563
565
  */
564
- const ranksAt = (siblings, index, count, clientId, rand) => {
566
+ const ranksAt = (siblings, index, count, clientId, rand, run = false) => {
565
567
  let left = siblings[index - 1]?.rank;
566
568
  const right = siblings[index]?.rank;
567
569
  if (left !== undefined && right !== undefined && left >= right) {
@@ -569,7 +571,10 @@ export const bindModel = (Y) => {
569
571
  }
570
572
  const out = [];
571
573
  for (let i = 0; i < count; i++) {
572
- const r = rankBetween(left, right, clientId, rand);
574
+ // An insert extends this client's run after a block it ranked (H1).
575
+ const r = run
576
+ ? rankAfter(left, right, clientId, rand)
577
+ : rankBetween(left, right, clientId, rand);
573
578
  out.push(r);
574
579
  left = r;
575
580
  }
@@ -749,12 +754,26 @@ export const bindModel = (Y) => {
749
754
  * slot ({@link displaySlotOf}). Content is each block's display (R2).
750
755
  */
751
756
  const project = (doc) => ({ children: R.attach(doc).project() });
752
- /** `positionOf` in a view: the display parent and the index among its visible children. */
757
+ /**
758
+ * `positionOf` in a view: the display parent and the index among its
759
+ * visible children — a binary search by the block's display rank (the
760
+ * list's own order), not a scan of its siblings.
761
+ */
753
762
  const positionInView = (v, id) => {
754
763
  if (!isLiveIn(v, id))
755
764
  return null;
756
- const dp = displayParentOf(v.own, v.placements.get(id), v.placements, id);
757
- const index = (v.kids.get(dp) ?? []).findIndex((s) => s.id === id);
765
+ const slot = displaySlotOf(v.own, v.placements, v.placements.get(id), id);
766
+ const dp = slot.parent;
767
+ const list = v.kids.get(dp) ?? [];
768
+ let [lo, hi] = [0, list.length];
769
+ while (lo < hi) {
770
+ const mid = (lo + hi) >> 1;
771
+ if (bySlot(list[mid], { id, rank: slot.rank }) < 0)
772
+ lo = mid + 1;
773
+ else
774
+ hi = mid;
775
+ }
776
+ const index = list[lo]?.id === id ? lo : list.findIndex((s) => s.id === id);
758
777
  return index < 0 ? null : { parent: dp, index };
759
778
  };
760
779
  /**
@@ -55,3 +55,23 @@ export declare const decodeRank: (rank: string) => RankSeg[];
55
55
  /** Encode segments back to the persisted string form. */
56
56
  export declare const encodeRank: (segs: readonly RankSeg[]) => string;
57
57
  export declare const rankBetween: (left: string | undefined, right: string | undefined, clientId: number, rand?: () => number) => string;
58
+ /**
59
+ * The digit of the run marker data arrays wrote up to 0.1.0-next.21 (a run
60
+ * of that form is the marker segment, then ranks of the client's own): still
61
+ * read as a run, so what a client inserts there stays in it.
62
+ */
63
+ export declare const RANK_RUN: number;
64
+ /**
65
+ * A rank in `(left, right)` for an insert right after `left` — one rule for
66
+ * array items (`crdt/data.ts`) and inserted blocks (H1, `order.insert.run`).
67
+ * When `client` made `left`, the rank is in the client's run after it: `left`
68
+ * itself as the prefix, then one segment of the run band — or, when `left`
69
+ * is already a member of the client's run, that run's prefix and a member
70
+ * between `left` and the next one. So what one client inserts after its own
71
+ * items stays together whatever a peer inserts in that gap meanwhile: a
72
+ * peer's rank there is either no extension of the run's prefix (it sorts
73
+ * after the whole run) or one whose next digit is above the band (YATA's
74
+ * origin rule, as `Y.Array` keeps an insert after its origin). Elsewhere, or
75
+ * when the run rank would not sort below `right`, a plain {@link rankBetween}.
76
+ */
77
+ export declare const rankAfter: (left: string | undefined, right: string | undefined, client: number, rand?: () => number) => string;
@@ -207,3 +207,82 @@ export const rankBetween = (left, right, clientId, rand = Math.random) => {
207
207
  path.push(lSeg);
208
208
  }
209
209
  };
210
+ /**
211
+ * The digit of the run marker data arrays wrote up to 0.1.0-next.21 (a run
212
+ * of that form is the marker segment, then ranks of the client's own): still
213
+ * read as a run, so what a client inserts there stays in it.
214
+ */
215
+ export const RANK_RUN = -(2 ** 39);
216
+ /**
217
+ * A run member's digit lies in `(RANK_RUN, RANK_RUN + RUN_BAND)`: below every
218
+ * digit a plain rank extends a prefix with (`0`, an append's), far above the
219
+ * digit minimum. A run opens at {@link RUN_START}, the middle of the band, so
220
+ * its members can be inserted before and after one another.
221
+ */
222
+ const RUN_BAND = 2 ** 38;
223
+ const RUN_START = RANK_RUN + 2 ** 37;
224
+ const inRun = (v) => v > RANK_RUN && v < RANK_RUN + RUN_BAND;
225
+ /**
226
+ * A rank in `(left, right)` for an insert right after `left` — one rule for
227
+ * array items (`crdt/data.ts`) and inserted blocks (H1, `order.insert.run`).
228
+ * When `client` made `left`, the rank is in the client's run after it: `left`
229
+ * itself as the prefix, then one segment of the run band — or, when `left`
230
+ * is already a member of the client's run, that run's prefix and a member
231
+ * between `left` and the next one. So what one client inserts after its own
232
+ * items stays together whatever a peer inserts in that gap meanwhile: a
233
+ * peer's rank there is either no extension of the run's prefix (it sorts
234
+ * after the whole run) or one whose next digit is above the band (YATA's
235
+ * origin rule, as `Y.Array` keeps an insert after its origin). Elsewhere, or
236
+ * when the run rank would not sort below `right`, a plain {@link rankBetween}.
237
+ */
238
+ export const rankAfter = (left, right, client, rand = Math.random) => {
239
+ const plain = () => rankBetween(left, right, client, rand);
240
+ if (left === undefined)
241
+ return plain();
242
+ const segs = decodeRank(left);
243
+ const last = segs.at(-1);
244
+ const bottom = encodeRank([{ v: RANK_RUN, t: 0 }]);
245
+ const top = encodeRank([{ v: RANK_RUN + RUN_BAND, t: 0 }]);
246
+ /**
247
+ * `prefix` and a rank after `after` (`undefined`: the run opens) in the
248
+ * run: inside the band when `band`. Opening, it takes the band's middle,
249
+ * or a digit below the run's first member; at the run's end it appends as
250
+ * an open end does (a window, not half the band); else between the two.
251
+ */
252
+ const own = (prefix, after, band) => {
253
+ const tail = (r) => r && r.length > prefix.length && r.startsWith(prefix) ? r.slice(prefix.length) : undefined;
254
+ let next = tail(right);
255
+ if (band && next !== undefined && next > top)
256
+ next = undefined;
257
+ let mine;
258
+ if (after === undefined) {
259
+ if (next === undefined)
260
+ mine = encodeRank([{ v: RUN_START, t: client }]);
261
+ else if (band && next > bottom)
262
+ mine = rankBetween(bottom, next, client, rand);
263
+ else
264
+ return plain();
265
+ }
266
+ else if (next === undefined && band) {
267
+ const append = rankBetween(after, undefined, client, rand);
268
+ mine = append < top ? append : rankBetween(after, top, client, rand);
269
+ }
270
+ else
271
+ mine = rankBetween(after, next, client, rand);
272
+ const rank = prefix + mine;
273
+ return right === undefined || rank < right ? rank : plain();
274
+ };
275
+ // A member of the client's run: the next member, inside the band.
276
+ if (last.t === client && inRun(last.v))
277
+ return own(encodeRank(segs.slice(0, -1)), encodeRank([last]), true);
278
+ // A run of the earlier form (the marker, then the client's ranks): stays in it.
279
+ const k = segs.findLastIndex((g) => g.v === RANK_RUN);
280
+ if (k >= 0 && segs[k].t === client) {
281
+ const prefix = encodeRank(segs.slice(0, k + 1));
282
+ return own(prefix, left.length > prefix.length ? left.slice(prefix.length) : undefined, false);
283
+ }
284
+ // A rank the client made: its run opens right after it.
285
+ if (last.t === client)
286
+ return own(left, undefined, true);
287
+ return plain();
288
+ };
@@ -25,7 +25,11 @@ export type StoreStruct = {
25
25
  /** `null` for sequence items; an attr name for map-entry items. */
26
26
  parentSub: string | null;
27
27
  parent: unknown;
28
+ left?: StoreStruct | null;
28
29
  right?: StoreStruct | null;
30
+ content?: {
31
+ arr?: unknown[];
32
+ };
29
33
  countable?: boolean;
30
34
  keep?: boolean;
31
35
  redone?: {
@@ -75,6 +79,12 @@ export declare const structAt: (Y: EngineApi, structs: readonly StoreStruct[], c
75
79
  * partial walk. `clientsOf` throws first when the store itself is absent.
76
80
  */
77
81
  export declare const walkIdSetStructs: (Y: EngineApi, doc: EngineDoc, idSet: IdSetLike, fn: (struct: StoreStruct) => boolean | void) => boolean;
82
+ /**
83
+ * {@link walkIdSetStructs}, clipped: `fn(struct, from, to)` gets the clock
84
+ * range `[from, to)` of the struct the id set covers (a struct can reach past
85
+ * a range a diff of two id sets cut).
86
+ */
87
+ export declare const walkIdSetRanges: (Y: EngineApi, doc: EngineDoc, idSet: IdSetLike, fn: (struct: StoreStruct, from: number, to: number) => void) => boolean;
78
88
  /**
79
89
  * The id the local `redone` chain leads `id` to: the copy an undo or redo
80
90
  * on THIS replica re-created of a deleted item (redo copies chain), or `id`
@@ -104,3 +114,10 @@ export declare const nextClock: (doc: EngineDoc, client: number) => number;
104
114
  * undefined until the missing update arrives.
105
115
  */
106
116
  export declare const holdsPending: (doc: EngineDoc) => boolean;
117
+ /**
118
+ * The transactions the engine has opened and not finished cleaning up
119
+ * (vendor-internal `doc._transactionCleanups`), in order: the open one, and
120
+ * those started during an earlier one's cleanup, whose bodies already ran
121
+ * and whose observers have not.
122
+ */
123
+ export declare const queuedTransactions: (doc: EngineDoc) => readonly unknown[];
@@ -75,6 +75,40 @@ export const walkIdSetStructs = (Y, doc, idSet, fn) => {
75
75
  });
76
76
  return complete;
77
77
  };
78
+ /**
79
+ * {@link walkIdSetStructs}, clipped: `fn(struct, from, to)` gets the clock
80
+ * range `[from, to)` of the struct the id set covers (a struct can reach past
81
+ * a range a diff of two id sets cut).
82
+ */
83
+ export const walkIdSetRanges = (Y, doc, idSet, fn) => {
84
+ const clients = clientsOf(doc);
85
+ let complete = true;
86
+ idSet.clients.forEach((ranges, client) => {
87
+ const structs = clients.get(client);
88
+ if (structs === undefined) {
89
+ complete = false;
90
+ return;
91
+ }
92
+ for (const r of ranges.getIds()) {
93
+ const end = r.clock + r.len;
94
+ let i;
95
+ try {
96
+ i = Y.findIndexSS(structs, r.clock);
97
+ }
98
+ catch {
99
+ complete = false;
100
+ continue;
101
+ }
102
+ for (; i < structs.length; i++) {
103
+ const s = structs[i];
104
+ if (s.id.clock >= end)
105
+ break;
106
+ fn(s, Math.max(r.clock, s.id.clock), Math.min(end, s.id.clock + s.length));
107
+ }
108
+ }
109
+ });
110
+ return complete;
111
+ };
78
112
  /**
79
113
  * The id the local `redone` chain leads `id` to: the copy an undo or redo
80
114
  * on THIS replica re-created of a deleted item (redo copies chain), or `id`
@@ -117,3 +151,10 @@ export const holdsPending = (doc) => {
117
151
  const store = doc.store;
118
152
  return (store?.pendingStructs ?? null) !== null || (store?.pendingDs ?? null) !== null;
119
153
  };
154
+ /**
155
+ * The transactions the engine has opened and not finished cleaning up
156
+ * (vendor-internal `doc._transactionCleanups`), in order: the open one, and
157
+ * those started during an earlier one's cleanup, whose bodies already ran
158
+ * and whose observers have not.
159
+ */
160
+ export const queuedTransactions = (doc) => doc._transactionCleanups ?? [];
@@ -195,9 +195,15 @@ export type Stream = {
195
195
  };
196
196
  /** Scan `text` (live space, renderer-free) for its boundary items. */
197
197
  export declare const scanText: (home: BlockId, text: EngineNode) => TextRow;
198
+ export declare const byId: (a: string, b: string) => number;
198
199
  /** Each block's delimiting boundary: the lowest-id live boundary whose nonce matches its record. */
199
200
  export declare const delimiters: (blocks: ReadonlyMap<BlockId, Pick<TextBlockRec, "n">>, rows: Iterable<TextRow>) => Map<BlockId, string>;
200
- /** The streams of one text, in text order: the home's head (unless delimited elsewhere), then one per delimiting boundary. */
201
+ /**
202
+ * The streams of one text, in text order: the home's head (unless delimited
203
+ * elsewhere), then one per delimiting boundary — one sweep over the
204
+ * boundaries (each non-delimiting one is an inert boundary of the stream it
205
+ * sits in).
206
+ */
201
207
  export declare const placeText: (row: TextRow, delim: ReadonlyMap<BlockId, string>) => Stream[];
202
208
  /** A displayed piece: `[i0, i1)` of `text` (home `t`) holds content only; `block` owns its stream. */
203
209
  export type Seg = {
@@ -221,6 +227,8 @@ export type Ownership = {
221
227
  streamOf: (b: BlockId) => Stream | undefined;
222
228
  /** The streams of `home`'s text, in text order. */
223
229
  streamsIn: (home: BlockId) => readonly Stream[];
230
+ /** The stream of `home`'s text whose `[start, end]` holds live index `i` (the first, at a seam). */
231
+ streamAt: (home: BlockId, i: number) => Stream | undefined;
224
232
  /** `display(b)` as boundary-free pieces in reading order; `null` when hidden or dead. */
225
233
  display: (b: BlockId) => Seg[] | null;
226
234
  };