edytor 5.0.0 → 7.0.0

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 (137) hide show
  1. package/README.md +3 -3
  2. package/dist/cloudflare/DocumentRoom.d.ts +77 -2
  3. package/dist/cloudflare/DocumentRoom.js +37 -1
  4. package/dist/cloudflare/index.d.ts +1 -1
  5. package/dist/cloudflare/index.js +1 -1
  6. package/dist/cloudflare/room/access.d.ts +6 -1
  7. package/dist/cloudflare/room/access.js +30 -4
  8. package/dist/cloudflare/room/admission.d.ts +11 -0
  9. package/dist/cloudflare/room/admission.js +43 -10
  10. package/dist/cloudflare/room/context.d.ts +73 -76
  11. package/dist/cloudflare/room/context.js +17 -6
  12. package/dist/cloudflare/room/forged.d.ts +1 -1
  13. package/dist/cloudflare/room/forged.js +11 -9
  14. package/dist/cloudflare/room/frames.d.ts +2 -0
  15. package/dist/cloudflare/room/frames.js +2 -0
  16. package/dist/cloudflare/room/presence.js +1 -4
  17. package/dist/cloudflare/room/reseed.d.ts +93 -0
  18. package/dist/cloudflare/room/reseed.js +500 -0
  19. package/dist/cloudflare/room/scheduler.d.ts +1 -1
  20. package/dist/cloudflare/room/scheduler.js +9 -1
  21. package/dist/cloudflare/room/shared.d.ts +10 -0
  22. package/dist/cloudflare/room/shared.js +10 -0
  23. package/dist/cloudflare/room/storage.d.ts +7 -0
  24. package/dist/cloudflare/room/storage.js +33 -1
  25. package/dist/components/Edytor.svelte +18 -3
  26. package/dist/components/Edytor.svelte.d.ts +10 -1
  27. package/dist/components/EdytorRoot.svelte +17 -0
  28. package/dist/components/EdytorRoot.svelte.d.ts +4 -0
  29. package/dist/components/Suggestion.svelte +1 -1
  30. package/dist/crdt/attribution/block.d.ts +3 -0
  31. package/dist/crdt/attribution/block.js +3 -2
  32. package/dist/crdt/doc/history.js +2 -3
  33. package/dist/crdt/doc/ops.d.ts +81 -0
  34. package/dist/crdt/doc/ops.js +1898 -0
  35. package/dist/crdt/doc/suggest.d.ts +6 -2
  36. package/dist/crdt/doc/suggest.js +6 -5
  37. package/dist/crdt/edytor-doc.d.ts +157 -157
  38. package/dist/crdt/edytor-doc.js +15 -43
  39. package/dist/crdt/engine-room.d.ts +4 -0
  40. package/dist/crdt/engine-room.js +6 -0
  41. package/dist/crdt/flow.d.ts +4 -46
  42. package/dist/crdt/flow.js +2 -262
  43. package/dist/crdt/index.d.ts +64 -75
  44. package/dist/crdt/operations.d.ts +1 -1
  45. package/dist/crdt/placement/resolve.d.ts +1 -1
  46. package/dist/crdt/protocol.d.ts +2 -2
  47. package/dist/crdt/protocol.js +2 -2
  48. package/dist/crdt/protocols/auth.d.ts +8 -1
  49. package/dist/crdt/protocols/auth.js +14 -1
  50. package/dist/crdt/protocols/awareness.js +46 -81
  51. package/dist/crdt/providers/index.d.ts +11 -22
  52. package/dist/crdt/providers/index.js +22 -3
  53. package/dist/crdt/providers/indexeddb.d.ts +6 -10
  54. package/dist/crdt/providers/indexeddb.js +10 -16
  55. package/dist/crdt/providers/room.d.ts +26 -0
  56. package/dist/crdt/providers/room.js +41 -12
  57. package/dist/crdt/providers/websocket.d.ts +10 -14
  58. package/dist/crdt/providers/websocket.js +43 -23
  59. package/dist/crdt/rangeDelete.d.ts +2 -47
  60. package/dist/crdt/rangeDelete.js +1 -169
  61. package/dist/crdt/reseed.d.ts +127 -0
  62. package/dist/crdt/reseed.js +533 -0
  63. package/dist/crdt/restore.d.ts +13 -1
  64. package/dist/crdt/restore.js +8 -3
  65. package/dist/crdt/schema.d.ts +6 -0
  66. package/dist/crdt/schema.js +6 -0
  67. package/dist/crdt/structs.d.ts +8 -11
  68. package/dist/crdt/structs.js +25 -44
  69. package/dist/crdt/text/index/build.d.ts +119 -0
  70. package/dist/crdt/text/index/build.js +2801 -0
  71. package/dist/crdt/text/index/shared.d.ts +2 -6
  72. package/dist/crdt/text/index/shared.js +2 -5
  73. package/dist/crdt/text/model.js +2 -3
  74. package/dist/crdt/text/runs.d.ts +9 -12
  75. package/dist/crdt/text/runs.js +2 -165
  76. package/dist/crdt/vendor/yjs/UPSTREAM.md +101 -5
  77. package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
  78. package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +10 -10
  79. package/dist/crdt/vendor/yjs/dts/utils/encoding.d.ts +1 -0
  80. package/dist/crdt/vendor/yjs/src/index.js +1 -1
  81. package/dist/crdt/vendor/yjs/src/structs/Item.js +24 -13
  82. package/dist/crdt/vendor/yjs/src/utils/Transaction.js +12 -10
  83. package/dist/crdt/vendor/yjs/src/utils/encoding.js +42 -1
  84. package/dist/events/domTextOffset.d.ts +2 -0
  85. package/dist/events/domTextOffset.js +8 -0
  86. package/dist/export/forms.d.ts +50 -50
  87. package/dist/index.d.ts +1 -1
  88. package/dist/index.js +1 -1
  89. package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
  90. package/dist/plugins/richtext/richTextOperations.js +26 -32
  91. package/dist/selection/selection.svelte.d.ts +1 -1
  92. package/dist/selection/selection.svelte.js +87 -106
  93. package/dist/selection/selection.utils.js +5 -8
  94. package/dist/session/commands.js +49 -51
  95. package/dist/session/handles.js +3 -7
  96. package/dist/surface/attributes.d.ts +3 -3
  97. package/dist/surface/attributes.js +2 -2
  98. package/dist/surface/observer.svelte.d.ts +7 -7
  99. package/dist/surface/observer.svelte.js +225 -336
  100. package/dist/surface/projector.svelte.js +2 -2
  101. package/dist/utils.d.ts +5 -0
  102. package/dist/utils.js +7 -0
  103. package/package.json +1 -1
  104. package/dist/crdt/doc/capability.d.ts +0 -56
  105. package/dist/crdt/doc/capability.js +0 -207
  106. package/dist/crdt/doc/content.d.ts +0 -16
  107. package/dist/crdt/doc/content.js +0 -77
  108. package/dist/crdt/doc/delete.d.ts +0 -15
  109. package/dist/crdt/doc/delete.js +0 -84
  110. package/dist/crdt/doc/layout.d.ts +0 -9
  111. package/dist/crdt/doc/layout.js +0 -178
  112. package/dist/crdt/doc/meta.d.ts +0 -19
  113. package/dist/crdt/doc/meta.js +0 -198
  114. package/dist/crdt/doc/moves.d.ts +0 -21
  115. package/dist/crdt/doc/moves.js +0 -274
  116. package/dist/crdt/doc/split-merge.d.ts +0 -10
  117. package/dist/crdt/doc/split-merge.js +0 -179
  118. package/dist/crdt/doc/steps.d.ts +0 -65
  119. package/dist/crdt/doc/steps.js +0 -311
  120. package/dist/crdt/text/index/cache.d.ts +0 -16
  121. package/dist/crdt/text/index/cache.js +0 -175
  122. package/dist/crdt/text/index/claims.d.ts +0 -18
  123. package/dist/crdt/text/index/claims.js +0 -203
  124. package/dist/crdt/text/index/fold.d.ts +0 -21
  125. package/dist/crdt/text/index/fold.js +0 -656
  126. package/dist/crdt/text/index/layout.d.ts +0 -22
  127. package/dist/crdt/text/index/layout.js +0 -258
  128. package/dist/crdt/text/index/placement.d.ts +0 -47
  129. package/dist/crdt/text/index/placement.js +0 -738
  130. package/dist/crdt/text/index/records.d.ts +0 -13
  131. package/dist/crdt/text/index/records.js +0 -139
  132. package/dist/crdt/text/index/report.d.ts +0 -18
  133. package/dist/crdt/text/index/report.js +0 -337
  134. package/dist/crdt/text/index/state.d.ts +0 -197
  135. package/dist/crdt/text/index/state.js +0 -116
  136. package/dist/crdt/text/index/streams.d.ts +0 -25
  137. package/dist/crdt/text/index/streams.js +0 -248
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
19
19
 
20
- > **5.0.** Edytor `5.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **7.0.** Edytor `7.0.0` is on npm as `edytor`: its API follows semantic versioning, and every change is listed in the changelog. It has no production track record yet: read the [limitations](https://edytor.dev/docs/reference/limitations) before you build on it. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
21
21
 
22
22
  ## Features
23
23
 
@@ -34,13 +34,13 @@ Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable
34
34
  - **Plugins that can veto anything.** Every command is prepared before it writes; plugins see the command and each planned step and can refuse or replace it.
35
35
  - **Real-time collaboration.** One `EdytorDocument` shared by any number of views, or none (headless). Presence cursors, identity-preserving moves, splits and merges, and undo that only takes back your own edits.
36
36
  - **Offline first.** A local IndexedDB copy and cross-tab sync; offline edits survive reloads and reach the server on reconnect.
37
- - **A Cloudflare Durable Object room.** `edytor/cloudflare` stores in SQLite, acknowledges after the write, binds identity to the socket and hibernates.
37
+ - **A Cloudflare Durable Object room.** `edytor/cloudflare` stores in SQLite, acknowledges after the write, binds identity to the socket and hibernates. A busy document is [re-seeded](https://edytor.dev/docs/server/room#re-seeding) from its content when it grows, offline edits merged when their device comes back.
38
38
  - **Import and export.** `edytor/export` turns a document into HTML or Markdown and back (`toHTML`, `toMarkdown`, `fromHTML`, `textToBlocks`), by each kind's own forms, in the browser, Node or a Worker; HTML import is sanitized ([Import and export](https://edytor.dev/docs/editor/import-export)).
39
39
  - **Headless and Worker-safe.** `edytor/crdt/edytor` runs in Node and Workers without Svelte.
40
40
 
41
41
  ## Install
42
42
 
43
- Edytor `5.0.0` is on npm under `latest`:
43
+ Edytor `7.0.0` is on npm under `latest`:
44
44
 
45
45
  ```bash
46
46
  pnpm add edytor
@@ -111,7 +111,7 @@ import type { CommitResult, ExportedBlocks, ImportReceipt, ImportRequest, LateEd
111
111
  import type { AwarenessEntry } from '../crdt/protocol.js';
112
112
  import type { DocumentSemanticsConfig, DocumentOperations, JSONBlock, JSONDoc, YDoc } from '../crdt/index.js';
113
113
  import type { CommentActor, CommentChange, CommentOutcome, CommentRequest, CommentSnapshot } from '../crdt/protocols/comments.js';
114
- export { closedSocket, COMMENTS_HEADER, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, MAX_REFUSALS, MAX_WAITING_DELETES, noTimers, PROBE_HEADER, PURGE_ORIGIN, RESTORE_ORIGIN, ROOM_ORIGIN, SOCKET_TAG } from './room/shared.js';
114
+ export { closedSocket, COMMENTS_HEADER, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, DEFAULT_RESEED_FORCE, DEFAULT_RESEED_GAIN, DEFAULT_RESEED_GRACE_DAYS, DEFAULT_RESEED_IDLE, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, MAX_REFUSALS, MAX_WAITING_DELETES, noTimers, PROBE_HEADER, PURGE_ORIGIN, RESTORE_ORIGIN, ROOM_ORIGIN, SOCKET_TAG } from './room/shared.js';
115
115
  /** Under SQLite-backed Durable Objects' 2 MB row cap, with room for the other columns. */
116
116
  export declare const DEFAULT_MAX_ROW_BYTES: number;
117
117
  /** Update records before the rows are merged into one snapshot. */
@@ -224,6 +224,8 @@ export type DocumentRoomEnv = {
224
224
  EDYTOR_MAX_PRESENCE_FANOUT?: string | number;
225
225
  /** `off`: the room logs nothing (default: one JSON line per compaction, quota hit, denial, fault). */
226
226
  EDYTOR_LOG?: string;
227
+ /** `off`: the room never re-seeds its document ({@link AttachRoomOptions.reseed}). */
228
+ EDYTOR_RESEED?: string;
227
229
  /**
228
230
  * Days after which deleted content is purged (default: the history
229
231
  * retention, else 30); `off` never purges.
@@ -279,9 +281,11 @@ export type SocketIdentity = {
279
281
  export type Attachment = SocketIdentity & {
280
282
  clock: number | null;
281
283
  comments?: boolean;
284
+ /** The older epoch the socket dialed: it may only hand over its old document (`room.reseed.protocol`). */
285
+ epoch?: number;
282
286
  };
283
287
  export type Refusal = {
284
- reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'suggest-only' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history';
288
+ reason: 'generation' | 'schema' | 'malformed' | 'container' | 'identity' | 'replica' | 'read-only' | 'waiting' | 'storage' | 'internal' | 'quota' | 'access' | 'presence' | 'comments' | 'mark' | 'suggest-only' | 'forged' | 'denied' | 'orphan' | 'relayed' | 'history' | 'epoch';
285
289
  detail: unknown;
286
290
  };
287
291
  /** A duration series: how many, their total and longest (ms; see {@link RoomMetrics}). */
@@ -346,6 +350,8 @@ export type RoomMetrics = {
346
350
  runs: number;
347
351
  horizon: number | null;
348
352
  };
353
+ /** The room's epoch (`room.reseed.protocol`): how many times its document was re-seeded. */
354
+ epoch: number;
349
355
  /** When this instance started (ms since the epoch). */
350
356
  since: number;
351
357
  };
@@ -402,6 +408,31 @@ export type RoomLogEntry = {
402
408
  role: 'out' | 'in';
403
409
  peer: string;
404
410
  blocks: number;
411
+ } | {
412
+ edytor: 'reseed';
413
+ epoch: number;
414
+ before: number;
415
+ after: number;
416
+ suggestions: number;
417
+ ms: number;
418
+ } | {
419
+ edytor: 'reseed';
420
+ skipped: 'suggestions';
421
+ bytes: number;
422
+ pending: number;
423
+ } | {
424
+ edytor: 'reseed';
425
+ skipped: 'gain';
426
+ bytes: number;
427
+ estimate: number;
428
+ } | {
429
+ edytor: 'rebase';
430
+ user: string;
431
+ from: number;
432
+ epoch: number;
433
+ status: 'noop' | 'applied' | 'refused' | 'failed';
434
+ bytes: number;
435
+ ms: number;
405
436
  } | {
406
437
  edytor: 'late';
407
438
  moveId: string;
@@ -476,6 +507,22 @@ export type CommentOptions = {
476
507
  */
477
508
  maxRequestsPerSecond?: number;
478
509
  };
510
+ /** When the room re-seeds its document (`AttachRoomOptions.reseed`, `room.reseed.timing`). */
511
+ export type ReseedOptions = {
512
+ /** Stored bytes past which a re-seed is due (default half of `maxDocumentBytes`). */
513
+ at?: number;
514
+ /** ms nobody may write before it runs (default {@link DEFAULT_RESEED_IDLE}, ten minutes). */
515
+ idleMs?: number;
516
+ /** The share of the stored bytes it must give back (default {@link DEFAULT_RESEED_GAIN}, 0.4). */
517
+ gain?: number;
518
+ /**
519
+ * The share of `maxDocumentBytes` past which it waits for no idle period
520
+ * and drops the pending suggestions (default {@link DEFAULT_RESEED_FORCE}, 0.8).
521
+ */
522
+ force?: number;
523
+ /** Days a cut is kept, so an offline client's edits still merge (default {@link DEFAULT_RESEED_GRACE_DAYS}, 30). */
524
+ graceDays?: number;
525
+ };
479
526
  /** What {@link attachRoom} (and `DocumentRoom`) takes. */
480
527
  export type AttachRoomOptions = {
481
528
  /**
@@ -583,6 +630,16 @@ export type AttachRoomOptions = {
583
630
  purgeAfterDays?: number | false;
584
631
  /** The room's clock, ms since the epoch (default `Date.now`): its slots, epochs and alarm read it. */
585
632
  now?: () => number;
633
+ /**
634
+ * Re-seed (`room.reseed.*`): a document that grew past `at` of its stored
635
+ * bytes (default half of `maxDocumentBytes`) is rebuilt from its content,
636
+ * with fresh identities, once nobody wrote for `idleMs` (default ten
637
+ * minutes) and when that gives back at least `gain` (0.4) of it; past
638
+ * `force` (0.8) of the quota at once. Every client reloads it; an offline
639
+ * client's unsaved edits are rebased onto it for `graceDays` (30). On by
640
+ * default; `false` never re-seeds.
641
+ */
642
+ reseed?: ReseedOptions | false;
586
643
  /**
587
644
  * Comment threads (`room.comments`): kept in the room's `threads` and
588
645
  * `comments` tables, read and written through `routeDocumentComments`
@@ -821,6 +878,15 @@ export declare class AttachedDocument {
821
878
  }) | null;
822
879
  /** The room's counters, also over RPC — see {@link RoomMetrics}. */
823
880
  metrics(): RoomMetrics;
881
+ /** The room's epoch (`room.reseed.protocol`): how many times its document was re-seeded. */
882
+ get epoch(): number;
883
+ /**
884
+ * Re-seed the document now (`room.reseed.cut`), whatever it gives back
885
+ * and whether a suggestion is pending (they are dropped): every socket
886
+ * is closed `4410` and reloads it. Answers the new epoch, or `null`
887
+ * when re-seeding is off or the room is inside a transaction.
888
+ */
889
+ reseed(): number | null;
824
890
  /**
825
891
  * Drop every waiting delete, stored or in memory (callable over RPC):
826
892
  * the way out when a writer filled `MAX_WAITING_DELETES` with
@@ -925,6 +991,11 @@ export declare class DocumentRoom<Env = DocumentRoomEnv> extends DurableObject<E
925
991
  * Read once, at the first frame.
926
992
  */
927
993
  protected locks(): LockOptions | undefined;
994
+ /**
995
+ * When the room re-seeds — see {@link AttachRoomOptions.reseed}. Default:
996
+ * on, with its default thresholds; `false` when `EDYTOR_RESEED` is `off`.
997
+ */
998
+ protected reseeding(): ReseedOptions | false | undefined;
928
999
  /**
929
1000
  * The rooms blocks move to — see {@link AttachRoomOptions.rooms}.
930
1001
  * Default: the `EDYTOR_ROOMS` binding; none without it.
@@ -946,6 +1017,10 @@ export declare class DocumentRoom<Env = DocumentRoomEnv> extends DurableObject<E
946
1017
  restoreHistory(key: string, options?: {
947
1018
  user?: string;
948
1019
  }): Promise<RestoreResult>;
1020
+ /** Re-seed the document now, also over RPC — see {@link AttachedDocument.reseed}. */
1021
+ reseed(): number | null;
1022
+ /** The room's epoch — see {@link AttachedDocument.epoch}. */
1023
+ get epoch(): number;
949
1024
  /** Undo the last restore, also over RPC — see {@link AttachedDocument.undoRestore}. */
950
1025
  undoRestore(options?: {
951
1026
  user?: string;
@@ -114,12 +114,13 @@ import { RoomHistory } from './room/history.js';
114
114
  import { RoomMoves } from './room/moves.js';
115
115
  import { RoomPresence } from './room/presence.js';
116
116
  import { RoomPurge } from './room/purge.js';
117
+ import { RoomReseed } from './room/reseed.js';
117
118
  import { ReplicaRegistry } from './room/replicas.js';
118
119
  import { Scheduler } from './room/scheduler.js';
119
120
  import { RoomStorage } from './room/storage.js';
120
121
  import { RoomValidation } from './room/validation.js';
121
122
  import { noTimers, SOCKET_TAG } from './room/shared.js';
122
- export { closedSocket, COMMENTS_HEADER, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, MAX_REFUSALS, MAX_WAITING_DELETES, noTimers, PROBE_HEADER, PURGE_ORIGIN, RESTORE_ORIGIN, ROOM_ORIGIN, SOCKET_TAG } from './room/shared.js';
123
+ export { closedSocket, COMMENTS_HEADER, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, DEFAULT_RESEED_FORCE, DEFAULT_RESEED_GAIN, DEFAULT_RESEED_GRACE_DAYS, DEFAULT_RESEED_IDLE, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, MAX_REFUSALS, MAX_WAITING_DELETES, noTimers, PROBE_HEADER, PURGE_ORIGIN, RESTORE_ORIGIN, ROOM_ORIGIN, SOCKET_TAG } from './room/shared.js';
123
124
  /** Under SQLite-backed Durable Objects' 2 MB row cap, with room for the other columns. */
124
125
  export const DEFAULT_MAX_ROW_BYTES = 2_000_000 - 4096;
125
126
  /** Update records before the rows are merged into one snapshot. */
@@ -245,6 +246,7 @@ export class AttachedDocument {
245
246
  purge: () => {
246
247
  room.purge.tick();
247
248
  },
249
+ reseed: () => room.reseed.task(),
248
250
  retention: () => room.history.expireVersions(),
249
251
  forward: () => room.moves.forwardMoves(),
250
252
  expiry: () => noTimers(() => room.access.expireSockets())
@@ -258,6 +260,7 @@ export class AttachedDocument {
258
260
  room.presence = new RoomPresence(room);
259
261
  room.access = new RoomAccess(room);
260
262
  room.comments = new RoomComments(room);
263
+ room.reseed = new RoomReseed(room);
261
264
  // The provider pings a silent socket: answer without waking the object.
262
265
  if (ctx.setWebSocketAutoResponse && !ctx.getWebSocketAutoResponse?.()) {
263
266
  ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
@@ -515,10 +518,24 @@ export class AttachedDocument {
515
518
  refusals: { ...this.refusalCounts },
516
519
  history: { ...counters.history },
517
520
  purge: { ...counters.purge },
521
+ epoch: room.reseed.epoch,
518
522
  since: counters.since
519
523
  };
520
524
  });
521
525
  }
526
+ /** The room's epoch (`room.reseed.protocol`): how many times its document was re-seeded. */
527
+ get epoch() {
528
+ return this.room.reseed.epoch;
529
+ }
530
+ /**
531
+ * Re-seed the document now (`room.reseed.cut`), whatever it gives back
532
+ * and whether a suggestion is pending (they are dropped): every socket
533
+ * is closed `4410` and reloads it. Answers the new epoch, or `null`
534
+ * when re-seeding is off or the room is inside a transaction.
535
+ */
536
+ reseed() {
537
+ return this.room.reseed.run('manual');
538
+ }
522
539
  /**
523
540
  * Drop every waiting delete, stored or in memory (callable over RPC):
524
541
  * the way out when a writer filled `MAX_WAITING_DELETES` with
@@ -644,6 +661,7 @@ export class DocumentRoom extends DurableObject {
644
661
  const semantics = () => this.semantics();
645
662
  const history = () => this.history();
646
663
  const locks = () => this.locks();
664
+ const reseeding = () => this.reseeding();
647
665
  const comments = () => String(knobs.EDYTOR_COMMENTS ?? '') === 'off'
648
666
  ? false
649
667
  : {
@@ -677,6 +695,9 @@ export class DocumentRoom extends DurableObject {
677
695
  get locks() {
678
696
  return locks();
679
697
  },
698
+ get reseed() {
699
+ return reseeding();
700
+ },
680
701
  get comments() {
681
702
  return comments();
682
703
  },
@@ -755,6 +776,13 @@ export class DocumentRoom extends DurableObject {
755
776
  const key = this.env.EDYTOR_LOCKS;
756
777
  return typeof key === 'string' && key !== '' ? { key } : undefined;
757
778
  }
779
+ /**
780
+ * When the room re-seeds — see {@link AttachRoomOptions.reseed}. Default:
781
+ * on, with its default thresholds; `false` when `EDYTOR_RESEED` is `off`.
782
+ */
783
+ reseeding() {
784
+ return String(this.env.EDYTOR_RESEED ?? '') === 'off' ? false : undefined;
785
+ }
758
786
  /**
759
787
  * The rooms blocks move to — see {@link AttachRoomOptions.rooms}.
760
788
  * Default: the `EDYTOR_ROOMS` binding; none without it.
@@ -794,6 +822,14 @@ export class DocumentRoom extends DurableObject {
794
822
  restoreHistory(key, options) {
795
823
  return this.room.restoreHistory(key, options);
796
824
  }
825
+ /** Re-seed the document now, also over RPC — see {@link AttachedDocument.reseed}. */
826
+ reseed() {
827
+ return this.room.reseed();
828
+ }
829
+ /** The room's epoch — see {@link AttachedDocument.epoch}. */
830
+ get epoch() {
831
+ return this.room.epoch;
832
+ }
797
833
  /** Undo the last restore, also over RPC — see {@link AttachedDocument.undoRestore}. */
798
834
  undoRestore(options) {
799
835
  return this.room.undoRestore(options);
@@ -4,7 +4,7 @@
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, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_DOCUMENT_WARNING, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_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 RestoreResult, type Timing } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, type AttachRoomOptions, type CommentOptions, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_DOCUMENT_WARNING, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, DEFAULT_RESEED_FORCE, DEFAULT_RESEED_GAIN, DEFAULT_RESEED_GRACE_DAYS, DEFAULT_RESEED_IDLE, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_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 ReseedOptions, type RestoreResult, type Timing } from './DocumentRoom.js';
8
8
  export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory, type HistoryEntry, type HistoryMetadata, type HistoryOptions, type HistoryRoomStorage, type HistoryStore, type HistoryStoreFactory, type HistoryStoreRecord, type KVLike, type R2BucketLike } from './history.js';
9
9
  export { type PurgeReport } from '../crdt/purge.js';
10
10
  export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment, type ThreadComment, type CommentActor, type CommentChange, type CommentOutcome, type CommentRefusal, type CommentRequest, type CommentRun, type CommentSnapshot, type CommentThread } from '../crdt/protocols/comments.js';
@@ -4,7 +4,7 @@
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, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_DOCUMENT_WARNING, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
7
+ export { DocumentRoom, AttachedDocument, attachRoom, SOCKET_TAG, closedSocket, DEFAULT_COMPACT_AFTER, DEFAULT_MAX_ROW_BYTES, DEFAULT_SAVE_AFTER, DEFAULT_MAX_DOCUMENT_BYTES, DEFAULT_DOCUMENT_WARNING, DEFAULT_MAX_INBOUND_FRAME_BYTES, DEFAULT_MAX_BUFFERED_BYTES, DEFAULT_MAX_UPDATES_PER_SECOND, DEFAULT_MAX_PRESENCE_BYTES, DEFAULT_MAX_PRESENCE_PER_SECOND, DEFAULT_MAX_PRESENCE_FANOUT, DEFAULT_MAX_COMMENT_BYTES, DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND, DEFAULT_RESEED_FORCE, DEFAULT_RESEED_GAIN, DEFAULT_RESEED_GRACE_DAYS, DEFAULT_RESEED_IDLE, MAX_REFUSALS, MAX_WAITING_DELETES, ROOM_ORIGIN, RESTORE_ORIGIN, PURGE_ORIGIN, IDENTITY_HEADERS, PROBE_HEADER, noTimers } from './DocumentRoom.js';
8
8
  export { DEFAULT_RETENTION_DAYS, HISTORY_MAX_VALUE_BYTES, R2_HISTORY_MAX_VALUE_BYTES, ROOM_HISTORY_MAX_BYTES, ROOM_HISTORY_MAX_VALUE_BYTES, kvHistory, r2History, roomHistory } from './history.js';
9
9
  export {} from '../crdt/purge.js';
10
10
  export { COMMENT_ROW_BYTES, COMMENT_STATUS, MAX_COMMENT_LENGTH, MAX_COMMENTS_PER_THREAD, MAX_THREADS, commentAnchors, commentBytes, decideComment } from '../crdt/protocols/comments.js';
@@ -7,7 +7,12 @@ export declare class RoomAccess {
7
7
  constructor(room: RoomContext);
8
8
  /** A request forwarded by the host Worker (`AttachedDocument.fetch`). */
9
9
  fetch(request: Request): Promise<Response>;
10
- /** Accept a verified socket: its replica bound to its user, then the join (Step1, presence). */
10
+ /**
11
+ * Accept a verified socket: its replica bound to its user, then the join
12
+ * (Step1, presence). A dial of another epoch than the room's is the
13
+ * re-seed's (`room.reseed.protocol`): closed, or joined as stale (no
14
+ * replica bound, no Step1: the room asks for its old document).
15
+ */
11
16
  private accept;
12
17
  /** The open sockets of `user`. */
13
18
  private socketsOf;
@@ -6,10 +6,11 @@
6
6
  * revocation, a changed access, an expired credential.
7
7
  */
8
8
  import { CLOSE } from '../../crdt/providers/room.js';
9
- import { COMMENTS_HEADER, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, PROBE_HEADER, SOCKET_TAG, closedSocket, noTimers } from './shared.js';
9
+ import { COMMENTS_HEADER, HISTORY_HEADER, HISTORY_KEY_HEADER, IDENTITY_HEADERS, PROBE_HEADER, SOCKET_TAG, STALE_TAG, closedSocket, noTimers } from './shared.js';
10
10
  import { STORAGE_FAILURE } from './context.js';
11
11
  import { accessFrame, readOnlyFrame, step1Frame } from './frames.js';
12
12
  import { StorageFault } from './replicas.js';
13
+ import { parseEpoch } from './reseed.js';
13
14
  import { stateVector } from './updates.js';
14
15
  /** The longest close reason the WebSocket API accepts, in UTF-8 bytes. */
15
16
  const MAX_CLOSE_REASON_BYTES = 123;
@@ -107,13 +108,38 @@ export class RoomAccess {
107
108
  if (identity === null)
108
109
  return new Response('verified identity required', { status: 401 });
109
110
  await room.storage.retryStart();
110
- return noTimers(() => this.accept(identity));
111
+ const epoch = parseEpoch(new URL(request.url).searchParams.get('epoch'));
112
+ return noTimers(() => this.accept(identity, epoch));
111
113
  }
112
- /** Accept a verified socket: its replica bound to its user, then the join (Step1, presence). */
113
- accept(identity) {
114
+ /**
115
+ * Accept a verified socket: its replica bound to its user, then the join
116
+ * (Step1, presence). A dial of another epoch than the room's is the
117
+ * re-seed's (`room.reseed.protocol`): closed, or joined as stale (no
118
+ * replica bound, no Step1: the room asks for its old document).
119
+ */
120
+ accept(identity, epoch) {
114
121
  const room = this.room;
115
122
  room.storage.heal();
116
123
  const doc = room.live;
124
+ const door = doc === null ? 'current' : room.reseed.door(epoch);
125
+ if (typeof door === 'object') {
126
+ room.note({
127
+ reason: 'epoch',
128
+ detail: { user: identity.user, epoch, room: room.reseed.epoch }
129
+ });
130
+ return closedSocket(door.code, door.reason);
131
+ }
132
+ if (door === 'stale') {
133
+ const pair = new WebSocketPair();
134
+ room.ctx.acceptWebSocket(pair[1], [SOCKET_TAG, STALE_TAG]);
135
+ pair[1].serializeAttachment({
136
+ ...identity,
137
+ clock: null,
138
+ epoch: epoch
139
+ });
140
+ room.reseed.join(pair[1], identity);
141
+ return new Response(null, { status: 101, webSocket: pair[0] });
142
+ }
117
143
  if (doc !== null && identity.replica !== null) {
118
144
  let bound;
119
145
  try {
@@ -1,7 +1,11 @@
1
+ import type { YDoc } from '../../crdt/index.js';
2
+ import type { Attachment } from '../DocumentRoom.js';
1
3
  import { type RoomContext } from './context.js';
2
4
  /** The provider's keepalive text frame, and the room's answer. */
3
5
  export declare const PING = "ping";
4
6
  export declare const PONG = "pong";
7
+ /** Seconds of the update rate a socket may spend at once. */
8
+ export declare const BURST_SECONDS = 10;
5
9
  /** A token bucket: its tokens, refilled since `at` (ms). */
6
10
  export type Bucket = {
7
11
  tokens: number;
@@ -40,6 +44,13 @@ export declare class Admission {
40
44
  sequences: number;
41
45
  bytes: number;
42
46
  };
47
+ /**
48
+ * A stale socket's rebase (`room.reseed.rebase`): `update`, the writes
49
+ * of a copy of the document under a client id of their own, admitted as
50
+ * its user's update, from that id. `true` once applied; refused (a
51
+ * quota, a read-only socket, a fault), `false`.
52
+ */
53
+ admitRebase(ws: WebSocket, attachment: Attachment, doc: YDoc, update: Uint8Array, writer: number): boolean;
43
54
  /**
44
55
  * The update rate quota: a token bucket per socket, refilled at
45
56
  * `maxUpdatesPerSecond` up to ten seconds' worth. `false`: over it.
@@ -31,7 +31,7 @@ const writes = (doc, decoded) => newWriters(decoded, stateVector(doc)).size > 0
31
31
  export const PING = 'ping';
32
32
  export const PONG = 'pong';
33
33
  /** Seconds of the update rate a socket may spend at once. */
34
- const BURST_SECONDS = 10;
34
+ export const BURST_SECONDS = 10;
35
35
  /**
36
36
  * A token bucket per key (a socket, a user), refilled at `perSecond` up to
37
37
  * ten seconds' worth: takes one token. `false`: over the rate.
@@ -106,10 +106,10 @@ export class Admission {
106
106
  /** One frame: whole as it came, or reassembled from its chunks. */
107
107
  onFrame(ws, attachment, doc, bytes) {
108
108
  const room = this.room;
109
- const limit = room.limits.maxInboundFrameBytes;
109
+ const limit = this.#frameLimit(attachment);
110
110
  // The frame quota: never decoded past it.
111
111
  if (bytes.length > limit) {
112
- return this.#overQuota(ws, attachment.user, 'frame', { bytes: bytes.length, limit });
112
+ return this.#overQuota(ws, attachment, 'frame', { bytes: bytes.length, limit });
113
113
  }
114
114
  const decoder = E.createDecoder(bytes);
115
115
  // 1 · Admission: the generation word, before anything is decoded.
@@ -120,6 +120,9 @@ export class Admission {
120
120
  const type = decode(() => E.readVarUint(decoder));
121
121
  if (type === E.messageChunk)
122
122
  return this.#onChunk(ws, attachment, doc, decoder);
123
+ // A socket of an older epoch hands over its old document, nothing else (`room.reseed.protocol`).
124
+ if (attachment.epoch != null)
125
+ return room.reseed.onStale(ws, attachment, type, decoder);
123
126
  if (type === E.messageSync)
124
127
  return this.onSync(ws, attachment, doc, decoder);
125
128
  if (type === E.messageAwareness) {
@@ -160,7 +163,7 @@ export class Admission {
160
163
  const room = this.room;
161
164
  let read = this.#chunkReaders.get(ws);
162
165
  if (read === undefined) {
163
- read = E.createChunkReader(room.limits.maxInboundFrameBytes, (total) => this.admitChunks(ws, total));
166
+ read = E.createChunkReader(this.#frameLimit(attachment), (total) => this.admitChunks(ws, total));
164
167
  this.#chunkReaders.set(ws, read);
165
168
  }
166
169
  let whole;
@@ -170,14 +173,14 @@ export class Admission {
170
173
  catch (error) {
171
174
  this.#chunkReaders.delete(ws);
172
175
  if (error instanceof ChunkError && error.limit !== undefined) {
173
- return this.#overQuota(ws, attachment.user, 'frame', {
176
+ return this.#overQuota(ws, attachment, 'frame', {
174
177
  bytes: error.total,
175
178
  limit: error.limit
176
179
  });
177
180
  }
178
181
  if (error instanceof ChunksRefused) {
179
182
  if (error.quota === 'rate') {
180
- return this.#overQuota(ws, attachment.user, 'rate', error.detail);
183
+ return this.#overQuota(ws, attachment, 'rate', error.detail);
181
184
  }
182
185
  // The room's buffer is full, not the sender at fault: it redials.
183
186
  room.note({
@@ -243,16 +246,39 @@ export class Admission {
243
246
  * reports (`onSyncRefused`) and does not redial. Never a dropped frame
244
247
  * on a live socket: the sender's later frames would build on it and
245
248
  * wait in the room's memory for good, unstored and unacknowledged; and
246
- * a redial would resend it and meet the same quota.
249
+ * a redial would resend it and meet the same quota. A socket of an older
250
+ * epoch whose hand-over is past the frame quota is closed `4410`: the
251
+ * room cannot read it, its client loads the room's document.
247
252
  */
248
- #overQuota(ws, user, quota, detail) {
253
+ #overQuota(ws, { user, epoch }, quota, detail) {
249
254
  const room = this.room;
250
255
  room.note({ reason: 'quota', detail: { user, quota, ...detail } });
251
256
  room.log({ edytor: 'quota', user, quota });
252
257
  this.#chunkReaders.delete(ws);
253
258
  room.presence.depart(ws);
259
+ if (epoch != null && quota === 'frame')
260
+ return room.close(ws, CLOSE.reseeded, String(room.reseed.epoch));
254
261
  room.close(ws, CLOSE.quota, `quota: ${quota}`);
255
262
  }
263
+ /** The frame quota of `attachment`'s socket: a stale one's hand-over is its whole old document. */
264
+ #frameLimit(attachment) {
265
+ const room = this.room;
266
+ return attachment.epoch != null
267
+ ? room.reseed.staleFrameBytes
268
+ : room.limits.maxInboundFrameBytes;
269
+ }
270
+ /**
271
+ * A stale socket's rebase (`room.reseed.rebase`): `update`, the writes
272
+ * of a copy of the document under a client id of their own, admitted as
273
+ * its user's update, from that id. `true` once applied; refused (a
274
+ * quota, a read-only socket, a fault), `false`.
275
+ */
276
+ admitRebase(ws, attachment, doc, update, writer) {
277
+ if (attachment.readOnly)
278
+ return false;
279
+ this.onUpdate(ws, { ...attachment, replica: writer }, doc, update);
280
+ return ws.readyState === WebSocket.OPEN;
281
+ }
256
282
  /**
257
283
  * The update rate quota: a token bucket per socket, refilled at
258
284
  * `maxUpdatesPerSecond` up to ten seconds' worth. `false`: over it.
@@ -296,7 +322,7 @@ export class Admission {
296
322
  const syncType = decode(() => E.readVarUint(decoder));
297
323
  // The rate quota counts every sync message, a Step1 too (it costs a Step2).
298
324
  if (!this.allow(ws)) {
299
- return this.#overQuota(ws, attachment.user, 'rate', {
325
+ return this.#overQuota(ws, attachment, 'rate', {
300
326
  perSecond: room.limits.maxUpdatesPerSecond
301
327
  });
302
328
  }
@@ -335,6 +361,13 @@ export class Admission {
335
361
  // 3 · Attribution: new structs only under client ids this user may
336
362
  // write under; another user's are stripped, the rest applied.
337
363
  const decoded = decode(() => Y.decodeUpdate(update));
364
+ // An older epoch's content (`room.reseed.protocol`): the socket's
365
+ // document took it from another tab; it loads the room's again.
366
+ if (room.reseed.holdsOld(decoded, doc)) {
367
+ room.note({ reason: 'epoch', detail: { user: attachment.user, epoch: room.reseed.epoch } });
368
+ room.presence.depart(ws);
369
+ return room.close(ws, CLOSE.reseeded, String(room.reseed.epoch));
370
+ }
338
371
  // A storage outage (`room.storage.outage`): a frame that would write
339
372
  // waits for storage to answer, unapplied, so the outage rebuilds once.
340
373
  if (storage.outage !== null && writes(doc, decoded) && !storage.answers()) {
@@ -343,7 +376,7 @@ export class Admission {
343
376
  // The document quota, net of what the frame deletes.
344
377
  if (decoded.structs.some((struct) => !(struct instanceof Y.Skip)) &&
345
378
  this.#overDocument(update.length, freedBy(doc, decoded.ds))) {
346
- return this.#overQuota(ws, attachment.user, 'document', {
379
+ return this.#overQuota(ws, attachment, 'document', {
347
380
  bytes: storage.documentBytes,
348
381
  incoming: update.length,
349
382
  limit: room.limits.maxDocumentBytes