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
@@ -4,5 +4,5 @@
4
4
  * `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
5
5
  * the site's `server/` section (room, authorization, extending, protocol).
6
6
  */
7
- export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, type AttachDocumentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, type AttachDocumentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers, type Attachment, type DocumentRoomEnv, type LoadedDocument, type Refusal, type ReplicaOwner, type SavedDocument, type SocketIdentity, type StoredRecord, type FrameValidation, type ValidatedBlock, type RoomMetrics, type RoomLogEntry, type Timing } from './DocumentRoom.js';
8
8
  export { routeDocumentSocket, requestedReplica, type AuthorizeDocumentSocket, type ExpiredCredential, type DocumentNamespace, type DocumentIdentity } from './routeDocumentSocket.js';
@@ -4,5 +4,5 @@
4
4
  * `cloudflare:workers`); built on the Worker-safe CRDT entry. Documented in
5
5
  * the site's `server/` section (room, authorization, extending, protocol).
6
6
  */
7
- export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, noTimers } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachDocument, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
8
8
  export { routeDocumentSocket, requestedReplica } from './routeDocumentSocket.js';
@@ -13,21 +13,42 @@
13
13
  * with its `params` read again, so a refreshed token gets in. A document
14
14
  * id the room cannot have (`validRoomId`: empty, `.` or `..`, over 256
15
15
  * characters, with a lone surrogate) is closed `4400`.
16
+ *
17
+ * A plain `GET <room>?lastUpdated` (no upgrade) is the room's probe (H12):
18
+ * authorized the same way, it answers `{ lastUpdated }` as JSON (the room's
19
+ * last stored change, ms since the epoch, or `null`), or `400`, `401` or
20
+ * `403` as an HTTP status.
16
21
  */
17
22
  import { CLOSE, validRoomId } from '../crdt/providers/room.js';
18
- import { IDENTITY_HEADERS, closedSocket, parseReplica } from './DocumentRoom.js';
23
+ import { IDENTITY_HEADERS, PROBE_HEADER, closedSocket, parseReplica } from './DocumentRoom.js';
24
+ /** The HTTP status a `lastUpdated` probe gets for each refusal a dial is closed with. */
25
+ const PROBE_STATUS = {
26
+ [CLOSE.invalidDocument]: 400,
27
+ [CLOSE.expired]: 401,
28
+ [CLOSE.denied]: 403
29
+ };
19
30
  /** The replica a client asked for in `?replica=<doc.clientID>` (or `param`), or `null`. */
20
31
  export const requestedReplica = (request, param = 'replica') => parseReplica(new URL(request.url).searchParams.get(param));
21
32
  export async function routeDocumentSocket(request, rooms, documentId, authorize) {
22
- if (request.method !== 'GET' || request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
33
+ // The `lastUpdated` probe (H12): a plain GET, authorized like a dial,
34
+ // answered with JSON (`{ lastUpdated }`) or an HTTP status.
35
+ const probe = request.method === 'GET' &&
36
+ request.headers.get('Upgrade') === null &&
37
+ new URL(request.url).searchParams.has('lastUpdated');
38
+ if (!probe &&
39
+ (request.method !== 'GET' || request.headers.get('Upgrade')?.toLowerCase() !== 'websocket')) {
23
40
  return new Response('WebSocket upgrade required', { status: 426 });
24
41
  }
42
+ // One refusal path: a dial gets the close the provider reads, a probe its HTTP status.
43
+ const refuse = (code, reason) => probe
44
+ ? new Response(reason, { status: PROBE_STATUS[code] ?? 403 })
45
+ : closedSocket(code, reason);
25
46
  if (!validRoomId(documentId)) {
26
- return closedSocket(CLOSE.invalidDocument, 'invalid document id');
47
+ return refuse(CLOSE.invalidDocument, 'invalid document id');
27
48
  }
28
49
  const decision = await authorize(request, documentId);
29
50
  if (decision && 'expired' in decision && decision.expired === true) {
30
- return closedSocket(CLOSE.expired, 'expired');
51
+ return refuse(CLOSE.expired, 'expired');
31
52
  }
32
53
  const identity = decision && 'userId' in decision ? decision : null;
33
54
  const replica = identity?.replica ?? null;
@@ -38,13 +59,16 @@ export async function routeDocumentSocket(request, rooms, documentId, authorize)
38
59
  // No header, even percent-encoded, carries a lone surrogate.
39
60
  /\p{Cs}/u.test(identity.userId) ||
40
61
  (replica !== null && parseReplica(replica) === null)) {
41
- return closedSocket(CLOSE.denied, 'document access denied');
62
+ return refuse(CLOSE.denied, 'document access denied');
42
63
  }
43
64
  const headers = new Headers({
44
- Upgrade: 'websocket',
45
65
  [IDENTITY_HEADERS.user]: encodeURIComponent(identity.userId),
46
66
  [IDENTITY_HEADERS.access]: identity.readOnly ? 'read' : 'write'
47
67
  });
68
+ if (probe)
69
+ headers.set(PROBE_HEADER, 'lastUpdated');
70
+ else
71
+ headers.set('Upgrade', 'websocket');
48
72
  if (replica !== null)
49
73
  headers.set(IDENTITY_HEADERS.replica, String(replica));
50
74
  return rooms.getByName(documentId).fetch(new Request(request.url, { method: 'GET', headers }));
@@ -68,10 +68,16 @@ export declare const mintPresenceKey: () => string;
68
68
  export declare const publishPresence: (awareness: AwarenessLike, key: string, payload: PresenceSelection | null) => boolean;
69
69
  /** What a view shares of its selection: the selection itself, the block holding its focus, or nothing. */
70
70
  export type PresenceShare = 'caret' | 'block' | 'none';
71
+ /**
72
+ * The least ms between two presence writes of a view by default (P9): a
73
+ * caret held down or a drag publishes about 20 writes a second instead of
74
+ * one per selection change, which every peer and the room relay.
75
+ */
76
+ export declare const DEFAULT_PRESENCE_THROTTLE = 50;
71
77
  export type PresenceOptions = {
72
78
  /** `'caret'` (default): the selection; `'block'`: the focused block only, so moving inside it publishes nothing; `'none'`: nothing. */
73
79
  share?: PresenceShare;
74
- /** Minimum ms between two presence writes (default `0`). The first goes at once; later ones collapse into one write of the newest at the window's end. */
80
+ /** Minimum ms between two presence writes (default {@link DEFAULT_PRESENCE_THROTTLE}, 50; `0`: every change). The first goes at once; later ones collapse into one write of the newest at the window's end. */
75
81
  throttle?: number;
76
82
  };
77
83
  /**
@@ -28,6 +28,12 @@ export const publishPresence = (awareness, key, payload) => {
28
28
  awareness.setLocalState(Object.keys(next).length > 0 ? { ...state, selections: next } : state);
29
29
  return true;
30
30
  };
31
+ /**
32
+ * The least ms between two presence writes of a view by default (P9): a
33
+ * caret held down or a drag publishes about 20 writes a second instead of
34
+ * one per selection change, which every peer and the room relay.
35
+ */
36
+ export const DEFAULT_PRESENCE_THROTTLE = 50;
31
37
  /**
32
38
  * One view's presence writer (R1): the only writer of its key, under the
33
39
  * view's `share` and `throttle`. `select()` hands it the full payload; the
@@ -44,7 +50,7 @@ export class PresenceWriter {
44
50
  #held;
45
51
  /** The last payload and focused block handed in, kept so a share change republishes at once. */
46
52
  #latest = [null, null];
47
- constructor(awareness, key, { share = 'caret', throttle = 0 } = {}) {
53
+ constructor(awareness, key, { share = 'caret', throttle = DEFAULT_PRESENCE_THROTTLE } = {}) {
48
54
  this.awareness = awareness;
49
55
  this.key = key;
50
56
  this.#share = share;
@@ -1,3 +1,3 @@
1
- export { freshestPublishedSelection, publishPresence, PresenceWriter, type PresenceOptions, type PresenceShare, type EdytorAwarenessSelection, type EdytorAwarenessState, type EdytorAwarenessUser, type EdytorAwarenessViewSelection } from './awarenessSelection.js';
1
+ export { freshestPublishedSelection, publishPresence, PresenceWriter, DEFAULT_PRESENCE_THROTTLE, type PresenceOptions, type PresenceShare, type EdytorAwarenessSelection, type EdytorAwarenessState, type EdytorAwarenessUser, type EdytorAwarenessViewSelection } from './awarenessSelection.js';
2
2
  export { whenDocumentReady } from './documentSync.js';
3
- export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, type EdytorSync, type EdytorSyncCleanup, type EdytorSyncPayload, type IndexeddbSyncOptions, type WebsocketSync, type WebsocketSyncOptions } from './providers.js';
3
+ export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, prefetch, lastUpdated, type EdytorSync, type EdytorSyncCleanup, type EdytorSyncPayload, type IndexeddbSyncOptions, type WebsocketSync, type WebsocketSyncOptions } from './providers.js';
@@ -1,3 +1,3 @@
1
- export { freshestPublishedSelection, publishPresence, PresenceWriter } from './awarenessSelection.js';
1
+ export { freshestPublishedSelection, publishPresence, PresenceWriter, DEFAULT_PRESENCE_THROTTLE } from './awarenessSelection.js';
2
2
  export { whenDocumentReady } from './documentSync.js';
3
- export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider } from './providers.js';
3
+ export { createIndexeddbSync, createWebsocketSync, clearDocument, IndexeddbPersistence, storeState, WebsocketProvider, prefetch, lastUpdated } from './providers.js';
@@ -11,3 +11,7 @@ export declare const clearDocument: ProviderStack['clearDocument'];
11
11
  export declare const storeState: ProviderStack['storeState'];
12
12
  export declare const IndexeddbPersistence: ProviderStack['IndexeddbPersistence'];
13
13
  export declare const WebsocketProvider: ProviderStack['WebsocketProvider'];
14
+ /** Keep a document's local copy fresh without opening it (sync once, then close). */
15
+ export declare const prefetch: ProviderStack['prefetch'];
16
+ /** When a room last stored a change: one authorized HTTP request, no document opened. */
17
+ export declare const lastUpdated: ProviderStack['lastUpdated'];
@@ -19,3 +19,7 @@ export const clearDocument = providers.clearDocument;
19
19
  export const storeState = providers.storeState;
20
20
  export const IndexeddbPersistence = providers.IndexeddbPersistence;
21
21
  export const WebsocketProvider = providers.WebsocketProvider;
22
+ /** Keep a document's local copy fresh without opening it (sync once, then close). */
23
+ export const prefetch = providers.prefetch;
24
+ /** When a room last stored a change: one authorized HTTP request, no document opened. */
25
+ export const lastUpdated = providers.lastUpdated;
@@ -12,7 +12,10 @@
12
12
  } from '../crdt/index.js';
13
13
  import { CLOSE, validRoomId } from '../crdt/providers/room.js';
14
14
  import type { EdytorSync, WebsocketSyncOptions } from '../collaboration/index.js';
15
- import type { PresenceOptions } from '../collaboration/awarenessSelection.js';
15
+ import {
16
+ DEFAULT_PRESENCE_THROTTLE,
17
+ type PresenceOptions
18
+ } from '../collaboration/awarenessSelection.js';
16
19
  import { createIndexeddbSync, createWebsocketSync } from '../collaboration/providers.js';
17
20
  export { EdytorClass as EdytorContext, useEdytor };
18
21
  import type { Placeholder, Plugin } from '../plugins.js';
@@ -138,6 +141,13 @@
138
141
  onSyncRefused?: (refusal: SyncRefusedError) => void;
139
142
  /** Advanced: a custom sync factory. Overrides `room`/`server`. */
140
143
  sync?: EdytorSync;
144
+ /**
145
+ * Keep the view read-only, and seed nothing, until its document was
146
+ * fetched once (a provider synced): a first visit offline shows no
147
+ * `value` that the room's content would later duplicate. For the
148
+ * document this view owns (with `document`, set it there). Read once.
149
+ */
150
+ requireHydration?: boolean;
141
151
  };
142
152
  </script>
143
153
 
@@ -167,6 +177,7 @@
167
177
  params,
168
178
  onSyncExpired,
169
179
  onSyncRefused,
180
+ requireHydration,
170
181
  awareness,
171
182
  actor,
172
183
  presence,
@@ -194,6 +205,7 @@
194
205
  doc,
195
206
  awareness,
196
207
  actor,
208
+ requireHydration,
197
209
  hotKeys,
198
210
  onSelectionChange,
199
211
  onChange,
@@ -273,7 +285,7 @@
273
285
  edytor.readonly = readonly;
274
286
  });
275
287
  $effect(() => {
276
- edytor.presence.throttle = presence?.throttle ?? 0;
288
+ edytor.presence.throttle = presence?.throttle ?? DEFAULT_PRESENCE_THROTTLE;
277
289
  edytor.presence.share = presence?.share ?? 'caret';
278
290
  });
279
291
  // A readonly change shows or hides chrome: the overlay repositions it.
@@ -1,7 +1,7 @@
1
1
  import { Edytor as EdytorClass, useEdytor, type Snippets } from '../edytor.svelte.js';
2
2
  import { SyncRefusedError, type Awareness, type DocumentActor, type EdytorDocument, type YDoc } from '../crdt/index.js';
3
3
  import type { EdytorSync, WebsocketSyncOptions } from '../collaboration/index.js';
4
- import type { PresenceOptions } from '../collaboration/awarenessSelection.js';
4
+ import { type PresenceOptions } from '../collaboration/awarenessSelection.js';
5
5
  export { EdytorClass as EdytorContext, useEdytor };
6
6
  import type { Placeholder, Plugin } from '../plugins.js';
7
7
  import { type BlockHandlesOptions } from '../plugins/blockHandles/blockHandlesPlugin.js';
@@ -68,6 +68,13 @@ export type EdytorProps = Snippets & {
68
68
  onSyncRefused?: (refusal: SyncRefusedError) => void;
69
69
  /** Advanced: a custom sync factory. Overrides `room`/`server`. */
70
70
  sync?: EdytorSync;
71
+ /**
72
+ * Keep the view read-only, and seed nothing, until its document was
73
+ * fetched once (a provider synced): a first visit offline shows no
74
+ * `value` that the room's content would later duplicate. For the
75
+ * document this view owns (with `document`, set it there). Read once.
76
+ */
77
+ requireHydration?: boolean;
71
78
  };
72
79
  import type { JSONBlock, JSONDoc } from '../utils/json.js';
73
80
  import type { HotKey, HotKeyCombination } from '../session/keymap.js';
@@ -137,5 +137,10 @@ export declare const bindAdmission: (Y: EngineApi) => {
137
137
  * Throws {@link UndecodableUpdateError} for payloads that fail to
138
138
  * decode/integrate at all, then the usual typed refusals.
139
139
  */
140
- admitUpdate: (update: Uint8Array, docName?: string) => YDoc;
140
+ admitUpdate: (update: Uint8Array | ReadonlyArray<Uint8Array | {
141
+ v2: Uint8Array;
142
+ }>, docName?: string, options?: {
143
+ /** Prepare the scratch doc before anything is applied (e.g. its `gcFilter`). */
144
+ prepare?: (doc: YDoc) => void;
145
+ }) => YDoc;
141
146
  };
@@ -133,10 +133,17 @@ export const bindAdmission = (Y) => ({
133
133
  * Throws {@link UndecodableUpdateError} for payloads that fail to
134
134
  * decode/integrate at all, then the usual typed refusals.
135
135
  */
136
- admitUpdate: (update, docName = 'loaded document') => {
136
+ admitUpdate: (update, docName = 'loaded document', options = {}) => {
137
137
  const doc = new Y.Doc();
138
+ options.prepare?.(doc);
139
+ const apply = (part) => part instanceof Uint8Array ? Y.applyUpdate(doc, part) : Y.applyUpdateV2(doc, part.v2);
138
140
  try {
139
- Y.applyUpdate(doc, update);
141
+ // Several updates (`{ v2 }`: in the v2 encoding) are applied in one
142
+ // transaction, with no merge pass.
143
+ if (update instanceof Uint8Array)
144
+ apply(update);
145
+ else
146
+ Y.transact(doc, () => update.forEach(apply));
140
147
  }
141
148
  catch (cause) {
142
149
  throw new UndecodableUpdateError(docName, cause);
@@ -57,6 +57,8 @@ export declare const keySegment: (k: string) => string;
57
57
  export declare const segmentKey: (s: string) => string;
58
58
  /** A new node's data, as leaves. */
59
59
  export declare const dataLeaves: (data: unknown) => Map<string, unknown>;
60
+ /** A path of the data as a leaf key (`d/` and its pointer). */
61
+ export declare const leafKey: (path: readonly string[]) => string;
60
62
  /** `node`'s data (`undefined` when it has none). */
61
63
  export declare const readData: (node: EngineNode) => JsonObj | undefined;
62
64
  /**
@@ -75,7 +77,7 @@ export declare const applyPatch: (data: JsonObj, patches: readonly DataPatch[])
75
77
  * they still read as the patched value, else it is exploded: every leaf
76
78
  * written, the attr deleted. An unchanged value writes nothing.
77
79
  */
78
- export declare const patchWrites: (node: EngineNode, patches: readonly DataPatch[], client?: number, rand?: () => number) => LeafWrite[] | null;
80
+ export declare const patchWrites: (node: EngineNode, patches: readonly DataPatch[], client?: number, rand?: () => number, atomic?: readonly string[]) => LeafWrite[] | null;
79
81
  /** Write `writes` on `node` (a detached one too: a block or atom being created). */
80
82
  export declare const writeLeaves: (node: EngineNode, writes: Iterable<LeafWrite>) => void;
81
83
  export {};
package/dist/crdt/data.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DATA, DATA_LEAF_PREFIX } from './schema.js';
2
- import { decodeRank, encodeRank, rankBetween } from './placement/rank.js';
2
+ import { rankAfter } from './placement/rank.js';
3
3
  import { hash32 } from './rand.js';
4
4
  import { jsonEquals } from '../utils/json.js';
5
5
  export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -17,13 +17,6 @@ export const keySegment = (k) => (k.startsWith('~') ? `~0${k.slice(1)}` : k);
17
17
  export const segmentKey = (s) => (s.startsWith('~0') ? `~${s.slice(2)}` : s);
18
18
  const RANK = /^(?:[-\w]{16})+$/;
19
19
  const index = (k) => (/^(0|[1-9]\d*)$/.test(k) ? Number(k) : -1);
20
- /**
21
- * The digit that opens a client's run after an item it made (`between`):
22
- * below every digit a plain insert extends a rank with (`0`, a gap's), so
23
- * the run sorts right after its item, and far above the digit minimum, so
24
- * the inserts before it (each one digit lower) never run out of room.
25
- */
26
- const RUN = -(2 ** 39);
27
20
  const rank = (v) => (typeof v === 'string' && RANK.test(v) ? v : undefined);
28
21
  /** Item `id`'s place: where a move put it (`id>`), else where it was made (`id#`). */
29
22
  const placeOf = (t, id) => rank(t.k.get(`${id}>`)?.v) ?? rank(t.k.get(`${id}#`)?.v);
@@ -103,28 +96,11 @@ const edits = (rand, client = 0) => {
103
96
  const remove = (t, id) => [id, `${id}#`, `${id}>`].forEach((s) => t.k.delete(s));
104
97
  /**
105
98
  * A rank in `(left, right)`. After an item this client placed, it is in
106
- * the client's run there (`left`, the `RUN` segment, then ranks of its
107
- * own): what it inserts after its own items stays together, as `Y.Array`
108
- * keeps an insert after its origin, whatever a peer inserts in that gap
109
- * meanwhile. Elsewhere a plain rank between the two.
99
+ * the client's run there (`rankAfter`): what it inserts after its own
100
+ * items stays together, as `Y.Array` keeps an insert after its origin,
101
+ * whatever a peer inserts in that gap meanwhile. Elsewhere a plain rank.
110
102
  */
111
- const between = (left, right) => {
112
- const plain = () => rankBetween(left, right, client, pick);
113
- if (left === undefined)
114
- return plain();
115
- const segs = decodeRank(left);
116
- const k = segs.findLastIndex((g) => g.v === RUN);
117
- const run = k >= 0 && segs[k].t === client
118
- ? encodeRank(segs.slice(0, k + 1))
119
- : segs.at(-1).t === client
120
- ? left + encodeRank([{ v: RUN, t: client }])
121
- : undefined;
122
- if (run === undefined)
123
- return plain();
124
- const tail = (r) => r && r.length > run.length && r.startsWith(run) ? r.slice(run.length) : undefined;
125
- const rank = run + rankBetween(tail(left), tail(right), client, pick);
126
- return right === undefined || rank < right ? rank : plain();
127
- };
103
+ const between = (left, right) => rankAfter(left, right, client, pick);
128
104
  /** Give `entries` (kept items, moved items, new values) their places in `t`, in order. */
129
105
  const lay = (t, entries) => {
130
106
  let left;
@@ -314,9 +290,35 @@ const effective = (base, leaves) => {
314
290
  under(`${key}/`, v);
315
291
  out.set(key, []);
316
292
  }
293
+ else if (isObject(v) && Object.keys(v).length) {
294
+ // An object written as one leaf (an atomic path, `data.atomic`) reads as its keys.
295
+ under(`${key}/`, v);
296
+ out.set(key, {});
297
+ }
317
298
  }
318
299
  return out;
319
300
  };
301
+ /** A path of the data as a leaf key (`d/` and its pointer). */
302
+ export const leafKey = (path) => DATA_LEAF_PREFIX + path.map((k) => enc(k)).join('/');
303
+ /**
304
+ * `leaves` with every atomic path (`data.atomic`, H8: a leaf key a kind
305
+ * declares) holding one leaf: the value of everything at and under it.
306
+ */
307
+ const collapse = (leaves, atomic) => {
308
+ if (atomic.length === 0)
309
+ return leaves;
310
+ const root = treeOf(leaves);
311
+ for (const key of atomic) {
312
+ let t = root;
313
+ for (const s of key.slice(DATA_LEAF_PREFIX.length).split('/'))
314
+ t = t?.k.get(s);
315
+ if (t === undefined || t.k.size === 0)
316
+ continue;
317
+ t.v = valueOf(t);
318
+ t.k.clear();
319
+ }
320
+ return leavesFrom(root);
321
+ };
320
322
  const read = (base, leaves) => valueOf(treeOf(effective(base, leaves)));
321
323
  /** `node`'s data (`undefined` when it has none). */
322
324
  export const readData = (node) => read(node.getAttr(DATA), leavesOf(node));
@@ -351,7 +353,7 @@ export const applyPatch = (data, patches) => {
351
353
  * they still read as the patched value, else it is exploded: every leaf
352
354
  * written, the attr deleted. An unchanged value writes nothing.
353
355
  */
354
- export const patchWrites = (node, patches, client = 0, rand) => {
356
+ export const patchWrites = (node, patches, client = 0, rand, atomic = []) => {
355
357
  const [base, now] = [node.getAttr(DATA), leavesOf(node)];
356
358
  const was = effective(base, now);
357
359
  const root = treeOf(was);
@@ -359,13 +361,21 @@ export const patchWrites = (node, patches, client = 0, rand) => {
359
361
  const { apply } = edits(rand, client);
360
362
  if (!patches.every((p) => apply(root, p)))
361
363
  return null;
362
- const [want, value] = [leavesFrom(root), valueOf(root)];
364
+ const value = valueOf(root);
363
365
  if (jsonEquals(before, value))
364
366
  return [];
367
+ // An atomic path is written as one leaf, and every leaf stored under it goes (H8).
368
+ const want = collapse(leavesFrom(root), atomic);
369
+ const under = (key) => atomic.some((a) => key.startsWith(`${a}/`));
365
370
  const diff = (from) => [...new Set([...from.keys(), ...want.keys()])]
366
371
  .filter((key) => !jsonEquals(from.get(key), want.get(key)))
367
372
  .map((key) => [key, want.get(key)]);
368
- const own = diff(was);
373
+ const own = [
374
+ ...diff(collapse(was, atomic)),
375
+ ...[...now.keys()]
376
+ .filter((key) => under(key) && !want.has(key))
377
+ .map((key) => [key, undefined])
378
+ ];
369
379
  const after = new Map(now);
370
380
  for (const [key, value] of own)
371
381
  value === undefined ? after.delete(key) : after.set(key, value);
@@ -52,11 +52,14 @@ export type DocumentOptions = {
52
52
  * History tuning — applied when the document's undo manager attaches.
53
53
  * `captureTimeout` is the merge window in ms (engine default 500):
54
54
  * commits closer than this fuse into one undo step; `0` makes every
55
- * commit its own step. Stacks are unbounded regardless — see
56
- * {@link EdytorDocument.clearHistory} for the pruning surface.
55
+ * commit its own step. `limit` is the number of undo steps kept (default
56
+ * {@link DEFAULT_HISTORY_LIMIT}, 200; `Infinity` keeps every step): an
57
+ * older step is dropped and the deleted content it kept is collected.
58
+ * {@link EdytorDocument.clearHistory} drops both stacks.
57
59
  */
58
60
  history?: {
59
61
  captureTimeout?: number;
62
+ limit?: number;
60
63
  };
61
64
  /**
62
65
  * Opt-in per-block lineage ring — `depth > 0` captures a subtree
@@ -71,6 +74,17 @@ export type DocumentOptions = {
71
74
  lineage?: {
72
75
  depth?: number;
73
76
  };
77
+ /**
78
+ * Decide an EMPTY document only from a provider that synced (H12): the
79
+ * room answered, or a local store hydrated. A provider's bound or
80
+ * failure never seeds it, so a first visit offline writes nothing — no
81
+ * seed of `value` that a different seed in the room would later meet as
82
+ * duplicate blocks — and stays `pending` (a view keeps it read-only)
83
+ * until the document is fetched once. A document holding content is
84
+ * decided as before; an explicit {@link EdytorDocument.sync} still
85
+ * decides.
86
+ */
87
+ requireHydration?: boolean;
74
88
  };
75
89
  export type CreateDocumentOptions = DocumentOptions & {
76
90
  /**
@@ -158,10 +172,12 @@ export type EdytorDocumentInit = {
158
172
  semantics?: DocumentSemanticsConfig;
159
173
  history?: {
160
174
  captureTimeout?: number;
175
+ limit?: number;
161
176
  };
162
177
  lineage?: {
163
178
  depth?: number;
164
179
  };
180
+ requireHydration?: boolean;
165
181
  };
166
182
  /**
167
183
  * The assembled document — see the module header for the lifecycle,
@@ -253,6 +269,8 @@ export declare class EdytorDocument {
253
269
  private _defaultType;
254
270
  private readonly _historyOptions;
255
271
  private readonly _lineageDepth;
272
+ /** {@link DocumentOptions.requireHydration}: an empty document waits for a provider's `synced`. */
273
+ readonly requireHydration: boolean;
256
274
  /** Attached providers keyed by transport target (O75): one per target. */
257
275
  private _providers;
258
276
  private _pendingSyncs;
@@ -299,6 +317,12 @@ export declare class EdytorDocument {
299
317
  * @internal
300
318
  */
301
319
  get historyCaptureTimeout(): number | undefined;
320
+ /**
321
+ * The undo steps this document's history keeps (P6): the configured
322
+ * `history.limit`, else {@link DEFAULT_HISTORY_LIMIT}. The `attachDocument`
323
+ * dedupe check reads it (a reattach naming another limit is a conflict).
324
+ */
325
+ get historyLimit(): number;
302
326
  /**
303
327
  * The lineage ring depth this document was configured with — read by
304
328
  * the `attachDocument` dedupe compatibility check (a reattach naming
@@ -406,12 +430,11 @@ export declare class EdytorDocument {
406
430
  untrackOrigin: (origin: unknown) => void;
407
431
  private _attachHistory;
408
432
  /**
409
- * Prune the undo/redo stacks — the document's retention surface. The
410
- * engine keeps unbounded stacks: nothing is pruned implicitly (the
411
- * `captureTimeout` merge window only fuses adjacent commits into one
412
- * step). Call this to bound memory on long-lived documents, e.g. after
413
- * a save checkpoint. No-op while history has not attached (there is
414
- * nothing to clear — it does NOT force attach or throw on pending).
433
+ * Drop the undo/redo stacks. The undo stack already keeps only its
434
+ * newest `history.limit` steps (P6); call this where undo no longer makes
435
+ * sense, e.g. after a save checkpoint. No-op while history has not
436
+ * attached (there is nothing to clear — it does NOT force attach or throw
437
+ * on pending).
415
438
  */
416
439
  clearHistory: () => void;
417
440
  /** Local document transaction — headless edits, one undo step per call. */