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
@@ -0,0 +1,500 @@
1
+ /**
2
+ * The re-seed (`room.reseed.*`): once the stored document grew past a
3
+ * share of its quota (`at`, half of it by default) and nobody wrote for
4
+ * `idleMs`, the room rebuilds it from its content with fresh identities
5
+ * (`crdt/reseed.ts`) as a new epoch, when that gives back at least `gain`
6
+ * of it, keeps the cut (what an offline client's old document needs to
7
+ * merge), and closes every socket `4410`. Past `force` of the quota it
8
+ * waits neither for an idle period nor for the pending suggestions (it
9
+ * drops them), and still runs only when it gives back `gain`.
10
+ *
11
+ * A socket that dials an older epoch (`room.reseed.protocol`) is told the
12
+ * room's (`messageEpoch`) when it may write, and hands over its old
13
+ * document (`messageRebase`): its unsaved edits are rebased onto a copy of
14
+ * the current document, and what that wrote goes through the admission as
15
+ * an update of its user (`room.reseed.rebase`: the quotas, access,
16
+ * validation, attribution), then it is closed `4410` too; one that may not
17
+ * write is closed at once. Any other epoch the room cannot rebase (older
18
+ * than the cuts kept, `graceDays`, or later than the room's) is closed
19
+ * `4410` at once, and a dial that names none, in a room that re-seeded,
20
+ * `1008` (a client that predates the re-seed). A current socket that sends
21
+ * an older epoch's content (a tab that took it from another) is closed
22
+ * `4410` too.
23
+ */
24
+ import { Y } from '../../crdt/engine.js';
25
+ import * as E from '../../crdt/protocol.js';
26
+ import { CLOSE } from '../../crdt/providers/room.js';
27
+ import { baseOf, cutBetween, decodeCut, decodeMerged, encodeCut, encodeMerged, hasEdits, rebaseOnto, seedFrom } from '../../crdt/reseed.js';
28
+ import { asEngineDoc } from '../../crdt/structs.js';
29
+ import { suggestionsOf } from '../../crdt/doc/suggest.js';
30
+ import { EPOCH_ROOT } from '../../crdt/schema.js';
31
+ import { BURST_SECONDS, allowance } from './admission.js';
32
+ import { DEFAULT_RESEED_FORCE, DEFAULT_RESEED_GAIN, DEFAULT_RESEED_GRACE_DAYS, DEFAULT_RESEED_IDLE, noTimers } from './shared.js';
33
+ import { DAY, crdt, now } from './context.js';
34
+ import { decode, epochFrame } from './frames.js';
35
+ /** A share in (0, 1], else `fallback`. */
36
+ const share = (value, fallback) => {
37
+ const n = Number(value);
38
+ return n > 0 && n <= 1 ? n : fallback;
39
+ };
40
+ /** A rebase is a full rebuild of the document: one per user every ten seconds. */
41
+ const REBASES_PER_SECOND = 0.1;
42
+ /** A failed re-seed is not due again before this much later. */
43
+ const RESEED_RETRY = 60_000;
44
+ /** An epoch a dial names (`?epoch=`; `new`: a document that holds nothing yet), or `null`. */
45
+ export const parseEpoch = (raw) => {
46
+ if (raw === 'new')
47
+ return raw;
48
+ const n = raw !== null && /^\d{1,9}$/.test(raw) ? Number(raw) : NaN;
49
+ return Number.isSafeInteger(n) ? n : null;
50
+ };
51
+ export class RoomReseed {
52
+ room;
53
+ /** Each user's rebase allowance (memory: a wake refills it). */
54
+ #rebases = new Map();
55
+ /** When the last re-seed failed: none again before {@link RESEED_RETRY} (memory). */
56
+ #failedAt = -Infinity;
57
+ /** The client ids of the epochs the cuts kept (`null`: not read since they changed). */
58
+ #oldClients = null;
59
+ constructor(room) {
60
+ this.room = room;
61
+ }
62
+ /** The re-seed's settings (`null`: off). */
63
+ get config() {
64
+ const { options, limits } = this.room;
65
+ const o = options.reseed;
66
+ if (o === false)
67
+ return null;
68
+ const quota = limits.maxDocumentBytes;
69
+ const at = Number(o?.at);
70
+ const idle = Number(o?.idleMs);
71
+ const grace = Number(o?.graceDays);
72
+ return {
73
+ at: Number.isFinite(at) && at > 0 ? at : quota / 2,
74
+ idleMs: Number.isFinite(idle) && idle >= 0 ? idle : DEFAULT_RESEED_IDLE,
75
+ gain: share(o?.gain, DEFAULT_RESEED_GAIN),
76
+ force: share(o?.force, DEFAULT_RESEED_FORCE) * quota,
77
+ graceDays: Number.isFinite(grace) && grace > 0 ? grace : DEFAULT_RESEED_GRACE_DAYS
78
+ };
79
+ }
80
+ /** The room's epoch: what its document says (`epoch.n`, which only the room writes), 0 before a re-seed. */
81
+ get epoch() {
82
+ const n = this.room.live === null ? 0 : asEngineDoc(this.room.live).get(EPOCH_ROOT).getAttr('n');
83
+ return typeof n === 'number' ? n : 0;
84
+ }
85
+ #ensure() {
86
+ this.room.sql.exec(`CREATE TABLE IF NOT EXISTS ${this.room.tables.cuts} (
87
+ epoch INTEGER NOT NULL,
88
+ kind TEXT NOT NULL,
89
+ part INTEGER NOT NULL,
90
+ parts INTEGER NOT NULL,
91
+ at INTEGER NOT NULL,
92
+ bytes BLOB NOT NULL,
93
+ PRIMARY KEY (epoch, kind, part)
94
+ )`);
95
+ }
96
+ /** Store `bytes` as epoch `epoch`'s `kind` (`cut`, `merged:<user>`), in rows ≤ `maxRowBytes`. */
97
+ #write(epoch, kind, bytes, at) {
98
+ const { sql, tables, limits } = this.room;
99
+ this.#ensure();
100
+ sql.exec(`DELETE FROM ${tables.cuts} WHERE epoch = ? AND kind = ?`, epoch, kind);
101
+ const parts = Math.max(1, Math.ceil(bytes.length / limits.maxRowBytes));
102
+ for (let part = 0; part < parts; part++) {
103
+ const row = bytes.slice(part * limits.maxRowBytes, (part + 1) * limits.maxRowBytes);
104
+ sql.exec(`INSERT INTO ${tables.cuts} (epoch, kind, part, parts, at, bytes) VALUES (?, ?, ?, ?, ?, ?)`, epoch, kind, part, parts, at, row);
105
+ }
106
+ }
107
+ #read(epoch, kind) {
108
+ const { sql, tables } = this.room;
109
+ this.#ensure();
110
+ const rows = sql
111
+ .exec(`SELECT part, parts, at, bytes FROM ${tables.cuts} WHERE epoch = ? AND kind = ? ORDER BY part`, epoch, kind)
112
+ .toArray();
113
+ if (rows.length === 0 || rows.length !== rows[0].parts)
114
+ return null;
115
+ const bytes = new Uint8Array(rows.reduce((n, row) => n + row.bytes.byteLength, 0));
116
+ let offset = 0;
117
+ for (const row of rows) {
118
+ bytes.set(new Uint8Array(row.bytes), offset);
119
+ offset += row.bytes.byteLength;
120
+ }
121
+ return { bytes, at: rows[0].at };
122
+ }
123
+ /**
124
+ * The cuts from epoch `from` to the room's, oldest first (`null`: one is
125
+ * gone, or older than the grace: `room.reseed.grace`).
126
+ */
127
+ #cutsSince(from) {
128
+ return this.#cutBytesSince(from)?.map(decodeCut) ?? null;
129
+ }
130
+ /** {@link #cutsSince}, undecoded: a storage read, which may fault. */
131
+ #cutBytesSince(from) {
132
+ const grace = (this.config?.graceDays ?? DEFAULT_RESEED_GRACE_DAYS) * DAY;
133
+ const cuts = [];
134
+ for (let epoch = from + 1; epoch <= this.epoch; epoch++) {
135
+ const found = this.#read(epoch, 'cut');
136
+ if (found === null || found.at < this.room.clock() - grace)
137
+ return null;
138
+ cuts.push(found.bytes);
139
+ }
140
+ return cuts;
141
+ }
142
+ /** The bytes a re-seed that gave back too little left: not tried again under them (meta `reseed.floor`). */
143
+ #floor() {
144
+ const { sql, tables } = this.room;
145
+ return (sql
146
+ .exec(`SELECT value FROM ${tables.meta} WHERE key = 'reseed.floor'`)
147
+ .toArray()[0]?.value ?? 0);
148
+ }
149
+ /**
150
+ * A change was stored at `at` (`room.reseed.timing`): past the
151
+ * threshold the re-seed is due once nobody writes for `idleMs` (each
152
+ * change pushes it back), past `force` at once.
153
+ */
154
+ noteChange(at) {
155
+ const config = this.config;
156
+ if (config === null)
157
+ return;
158
+ const bytes = this.room.storage.documentBytes;
159
+ const { scheduler } = this.room;
160
+ // Under the floor a re-seed gave back too little: none until the document grows past it;
161
+ // after one that failed, none before its retry.
162
+ if (bytes < config.at || bytes < this.#floor() || at < this.#failedAt + RESEED_RETRY)
163
+ return;
164
+ if (bytes >= config.force)
165
+ scheduler.schedule('reseed', at, 'earlier');
166
+ else
167
+ scheduler.schedule('reseed', at + config.idleMs, 'replace');
168
+ }
169
+ /**
170
+ * The `reseed` task: the cuts past their grace go (`room.reseed.grace`),
171
+ * then it re-seeds when still due (past the threshold and the floor); a
172
+ * re-seed that failed is due again at the first change a minute later or
173
+ * more. It is due again when the oldest cut left expires.
174
+ */
175
+ task() {
176
+ const room = this.room;
177
+ noTimers(() => {
178
+ const config = this.config;
179
+ room.scheduler.unschedule('reseed');
180
+ this.prune(room.clock());
181
+ const bytes = room.storage.documentBytes;
182
+ if (config !== null && room.live !== null && bytes >= config.at && bytes >= this.#floor()) {
183
+ try {
184
+ this.run(bytes >= config.force ? 'full' : 'idle');
185
+ }
186
+ catch (error) {
187
+ // Not again before a minute: the next change past it schedules it, as usual.
188
+ room.note({ reason: 'internal', detail: `reseed: ${String(error)}` });
189
+ this.#failedAt = room.clock();
190
+ }
191
+ }
192
+ this.#prunesAt();
193
+ });
194
+ }
195
+ /** Due when the oldest cut kept passes its grace. */
196
+ #prunesAt() {
197
+ const { sql, tables, scheduler } = this.room;
198
+ this.#ensure();
199
+ const oldest = sql
200
+ .exec(`SELECT MIN(at) AS at FROM ${tables.cuts} WHERE kind = 'cut'`)
201
+ .toArray()[0]?.at;
202
+ const grace = (this.config?.graceDays ?? DEFAULT_RESEED_GRACE_DAYS) * DAY;
203
+ if (oldest != null)
204
+ scheduler.schedule('reseed', oldest + grace, 'earlier');
205
+ }
206
+ /**
207
+ * Re-seed now (`room.reseed.cut`): the new epoch replaces the rows, in
208
+ * one storage transaction with its cut; the replica registry, the
209
+ * purge's epochs and the last restore's step go (they name the old
210
+ * identities); the live document is rebuilt from the rows and every
211
+ * socket closed `4410`. `idle` (the task, once nobody wrote) waits while
212
+ * a suggestion is pending (an idle period later); `full` (the task, past
213
+ * `force` of the quota) drops them; both do nothing when the new epoch
214
+ * would give back less than `gain` (and wait for the document to grow by
215
+ * a quarter of `at` before trying again). `manual` (`reseed()`) runs
216
+ * whatever it gives back and drops them. Answers the new epoch, or `null`.
217
+ */
218
+ run(mode) {
219
+ const room = this.room;
220
+ const config = this.config;
221
+ if (config === null)
222
+ return null;
223
+ return noTimers(() => {
224
+ if (room.busy) {
225
+ room.scheduler.schedule('reseed', room.clock() + 60_000, 'earlier');
226
+ return null;
227
+ }
228
+ room.storage.heal();
229
+ const doc = room.requireDoc();
230
+ const facade = room.facade;
231
+ const old = { doc: asEngineDoc(doc), facade: facade };
232
+ const pending = suggestionsOf(facade).changes().length;
233
+ const before = room.storage.documentBytes;
234
+ if (pending > 0 && mode === 'idle') {
235
+ room.log({ edytor: 'reseed', skipped: 'suggestions', bytes: before, pending });
236
+ room.scheduler.schedule('reseed', room.clock() + config.idleMs, 'replace');
237
+ return null;
238
+ }
239
+ const started = now();
240
+ const scratch = crdt.createDoc();
241
+ const fresh = room.facadeOf(scratch);
242
+ try {
243
+ const epoch = this.epoch + 1;
244
+ const base = baseOf(old.facade);
245
+ const document = seedFrom(old, base, (json) => {
246
+ fresh.seed(json.children, json.data);
247
+ // The document says its epoch: a client dials with it (`room.reseed.protocol`).
248
+ asEngineDoc(scratch).get(EPOCH_ROOT).setAttr('n', epoch);
249
+ return { doc: asEngineDoc(scratch), facade: fresh };
250
+ });
251
+ const update = Y.encodeStateAsUpdate(scratch);
252
+ if (mode !== 'manual' && update.length > before * (1 - config.gain)) {
253
+ room.sql.exec(`INSERT OR REPLACE INTO ${room.tables.meta} (key, value) VALUES ('reseed.floor', ?)`, before + config.at / 4);
254
+ room.log({ edytor: 'reseed', skipped: 'gain', bytes: before, estimate: update.length });
255
+ return null;
256
+ }
257
+ const cut = cutBetween(old, base, document);
258
+ const at = room.clock();
259
+ const { sql, tables } = room;
260
+ room.storage.replaceWith(update, () => {
261
+ sql.exec(`DELETE FROM ${tables.replicas}`);
262
+ sql.exec(`DELETE FROM ${tables.epochs}`);
263
+ sql.exec(`DELETE FROM ${tables.restore}`);
264
+ sql.exec(`DELETE FROM ${tables.meta} WHERE key = 'reseed.floor'`);
265
+ this.#write(epoch, 'cut', encodeCut(cut), at);
266
+ });
267
+ this.#oldClients = null;
268
+ // The `onSave` mirror holds the new epoch, as the rows do.
269
+ room.storage.scheduleSave();
270
+ this.#prunesAt();
271
+ room.state.presence.clear();
272
+ room.storage.rebuild();
273
+ // A stale socket's hand-over still merges: the cuts it reads down are kept.
274
+ for (const ws of room.sockets()) {
275
+ room.presence.depart(ws);
276
+ room.close(ws, CLOSE.reseeded, String(epoch));
277
+ }
278
+ room.log({
279
+ edytor: 'reseed',
280
+ epoch,
281
+ before,
282
+ after: update.length,
283
+ suggestions: pending,
284
+ ms: now() - started
285
+ });
286
+ return epoch;
287
+ }
288
+ finally {
289
+ fresh.dispose();
290
+ scratch.destroy();
291
+ }
292
+ });
293
+ }
294
+ /**
295
+ * The door (`RoomAccess.accept`, `room.reseed.protocol`): a dial of
296
+ * epoch `asked` is current, stale (an older epoch whose cuts are kept),
297
+ * or closed: none, in a room that re-seeded, `1008` (a client that
298
+ * predates the re-seed); any other epoch the room cannot rebase (past
299
+ * the cuts kept, or later than its own: the room lost it), `4410`, so
300
+ * the client drops its copy and loads the room's.
301
+ */
302
+ door(asked) {
303
+ const current = this.epoch;
304
+ if (asked === 'new')
305
+ return 'current';
306
+ if (asked === null)
307
+ return current === 0 ? 'current' : { code: CLOSE.refused, reason: 'refused: epoch' };
308
+ if (asked === current)
309
+ return 'current';
310
+ if (asked < current && this.#cutsSince(asked) !== null)
311
+ return 'stale';
312
+ return { code: CLOSE.reseeded, reason: String(current) };
313
+ }
314
+ /**
315
+ * Whether `decoded`, from a socket of the current epoch, holds an older
316
+ * epoch's content (`room.reseed.protocol`): a struct of a client the
317
+ * cuts kept name, which the document holds nothing of (a tab took it
318
+ * from another tab of the old epoch). Its socket is closed `4410`.
319
+ */
320
+ holdsOld(decoded, doc) {
321
+ if (this.#oldClients?.size === 0 || this.epoch === 0)
322
+ return false;
323
+ if (this.#oldClients === null) {
324
+ const { sql, tables } = this.room;
325
+ this.#ensure();
326
+ const epochs = sql
327
+ .exec(`SELECT DISTINCT epoch FROM ${tables.cuts} WHERE kind = 'cut'`)
328
+ .toArray();
329
+ this.#oldClients = new Set();
330
+ for (const { epoch } of epochs) {
331
+ const found = this.#read(epoch, 'cut');
332
+ if (found === null)
333
+ continue;
334
+ for (const client of Y.decodeStateVector(decodeCut(found.bytes).sv).keys())
335
+ this.#oldClients.add(client);
336
+ }
337
+ }
338
+ const old = this.#oldClients;
339
+ const held = doc.store.clients;
340
+ return decoded.structs.some((struct) => !(struct instanceof Y.Skip) && old.has(struct.id.client) && !held.has(struct.id.client));
341
+ }
342
+ /** The largest frame a socket of an older epoch may send: its whole old document. */
343
+ get staleFrameBytes() {
344
+ const { maxInboundFrameBytes, maxDocumentBytes, maxBufferedBytes } = this.room.limits;
345
+ return Math.min(maxBufferedBytes, Math.max(maxInboundFrameBytes, 2 * maxDocumentBytes));
346
+ }
347
+ /**
348
+ * A stale socket joined: one that may write is told the room's epoch
349
+ * (its provider then hands over its old document); any other has no
350
+ * edit to merge and is closed `4410`. Past its user's rebase rate it is
351
+ * closed `1011` before it uploads anything: its provider dials again
352
+ * later.
353
+ */
354
+ join(ws, identity) {
355
+ const room = this.room;
356
+ if (identity.readOnly || identity.access === 'suggest') {
357
+ return room.close(ws, CLOSE.reseeded, String(this.epoch));
358
+ }
359
+ if (!this.#mayRebase(identity.user))
360
+ return room.close(ws, CLOSE.fault, 'rebase: rate');
361
+ room.send(ws, epochFrame(this.epoch));
362
+ }
363
+ /** Whether `user`'s rebase allowance holds a token now (it takes none). */
364
+ #mayRebase(user) {
365
+ const held = this.#rebases.get(user);
366
+ if (held === undefined)
367
+ return true;
368
+ const burst = REBASES_PER_SECOND * BURST_SECONDS;
369
+ const elapsed = Math.max(0, this.room.clock() - held.at) / 1000;
370
+ return Math.min(burst, held.tokens + elapsed * REBASES_PER_SECOND) >= 1;
371
+ }
372
+ /**
373
+ * A stale socket's message: its old document (`messageRebase`) is
374
+ * rebased onto a copy of the current one, and what that wrote is
375
+ * admitted as an update of its user, from a client id of its own (the
376
+ * admission's quotas, access, validation and attribution, as any
377
+ * update); then it is closed `4410`. The merged state is kept per user
378
+ * and cut (`merged:<user>`), so another tab's upload merges nothing
379
+ * twice and another user's cannot claim it. A hand-over that cannot be
380
+ * read or rebased is dropped, its socket closed `4410` all the same (its
381
+ * client loads the room's document); past the rebase rate (one per user
382
+ * every ten seconds), `1011`, and the provider hands over again later.
383
+ * Anything else a stale socket sends is dropped.
384
+ */
385
+ onStale(ws, attachment, type, decoder) {
386
+ const room = this.room;
387
+ if (type !== E.messageRebase)
388
+ return;
389
+ const from = attachment.epoch;
390
+ const started = now();
391
+ const done = (status, bytes) => {
392
+ room.log({
393
+ edytor: 'rebase',
394
+ user: attachment.user,
395
+ from,
396
+ epoch: this.epoch,
397
+ status,
398
+ bytes,
399
+ ms: now() - started
400
+ });
401
+ if (ws.readyState === WebSocket.OPEN)
402
+ room.close(ws, CLOSE.reseeded, String(this.epoch));
403
+ };
404
+ let upload;
405
+ let update;
406
+ let writer;
407
+ let merged;
408
+ const kind = `merged:${attachment.user}`;
409
+ // Read-only now (a downgrade since it joined): nothing to rebuild.
410
+ if (attachment.readOnly)
411
+ return done('refused', 0);
412
+ // The room's own reads: a storage fault closes `1011`, and the provider hands over again.
413
+ const kept = this.#cutBytesSince(from);
414
+ const found = this.#read(from + 1, kind);
415
+ if (kept === null)
416
+ return done('failed', 0);
417
+ let cuts;
418
+ try {
419
+ cuts = kept.map(decodeCut);
420
+ upload = decode(() => E.readVarUint8Array(decoder));
421
+ const before = found === null ? undefined : decodeMerged(found.bytes);
422
+ if (!hasEdits(upload, before ?? { sv: cuts[0].sv, ds: cuts[0].ds, maps: [] }))
423
+ return done('noop', upload.length);
424
+ if (!allowance(this.#rebases, attachment.user, REBASES_PER_SECOND, room.clock())) {
425
+ room.note({ reason: 'quota', detail: { user: attachment.user, quota: 'rebase' } });
426
+ return room.close(ws, CLOSE.fault, 'rebase: rate');
427
+ }
428
+ ({ update, writer, merged } = this.#rebase(attachment.user, cuts, upload, before));
429
+ }
430
+ catch (error) {
431
+ room.note({ reason: 'internal', detail: `rebase: ${String(error)}` });
432
+ return done('failed', 0);
433
+ }
434
+ if (update === null)
435
+ return done('noop', upload.length);
436
+ const doc = room.requireDoc();
437
+ // The rebase's writes, from a client id of their own: the admission's, as any update's.
438
+ const admitted = room.admission.admitRebase(ws, attachment, doc, update, writer);
439
+ if (!admitted)
440
+ return done('refused', upload.length);
441
+ try {
442
+ room.ctx.storage.transactionSync(() => this.#write(from + 1, kind, encodeMerged(merged), this.#read(from + 1, 'cut').at));
443
+ }
444
+ catch (error) {
445
+ // The edits are in: the client still drops its copy, never hands them over again.
446
+ room.note({ reason: 'storage', detail: `rebase: ${String(error)}` });
447
+ }
448
+ done('applied', upload.length);
449
+ }
450
+ /**
451
+ * Rebase `upload` onto a copy of the live document as `user`
452
+ * (`crdt/reseed.ts`): the update the copy's writes make (`null`:
453
+ * nothing to write), the client id they are under (the copy's own,
454
+ * random) and what this rebase merged.
455
+ */
456
+ #rebase(user, cuts, upload, merged) {
457
+ const room = this.room;
458
+ const live = room.requireDoc();
459
+ const scratch = [];
460
+ const open = (update, actor) => {
461
+ const doc = crdt.createDoc();
462
+ Y.applyUpdate(doc, update);
463
+ const facade = room.facadeOf(doc, actor);
464
+ scratch.push({ doc, facade });
465
+ return { doc: asEngineDoc(doc), facade: facade };
466
+ };
467
+ try {
468
+ const target = open(Y.encodeStateAsUpdate(live), { id: user });
469
+ const copy = scratch[0];
470
+ let result;
471
+ copy.facade.transact(() => {
472
+ result = rebaseOnto(target, cuts, upload, (update) => open(update), merged);
473
+ });
474
+ const update = result.report.status === 'noop'
475
+ ? null
476
+ : Y.encodeStateAsUpdate(copy.doc, Y.encodeStateVector(live));
477
+ return { update, writer: copy.doc.clientID, merged: result.merged };
478
+ }
479
+ finally {
480
+ for (const { doc, facade } of scratch) {
481
+ facade.dispose();
482
+ doc.destroy();
483
+ }
484
+ }
485
+ }
486
+ /** Drop the cuts older than the grace, with what their rebases merged (`room.reseed.grace`). */
487
+ prune(at) {
488
+ const config = this.config;
489
+ const { sql, tables } = this.room;
490
+ this.#ensure();
491
+ const cutoff = at - (config?.graceDays ?? DEFAULT_RESEED_GRACE_DAYS) * DAY;
492
+ const gone = sql
493
+ .exec(`SELECT DISTINCT epoch FROM ${tables.cuts} WHERE kind = 'cut' AND at < ?`, cutoff)
494
+ .toArray();
495
+ for (const { epoch } of gone)
496
+ sql.exec(`DELETE FROM ${tables.cuts} WHERE epoch = ?`, epoch);
497
+ if (gone.length > 0)
498
+ this.#oldClients = null;
499
+ }
500
+ }
@@ -1,6 +1,6 @@
1
1
  import type { RoomContext } from './context.js';
2
2
  /** The room's alarm tasks (`room.alarm.tasks`), in the order an alarm runs them. */
3
- export type Task = 'history' | 'save' | 'purge' | 'retention' | 'forward' | 'expiry';
3
+ export type Task = 'history' | 'save' | 'reseed' | 'purge' | 'retention' | 'forward' | 'expiry';
4
4
  /** What runs each task: a promise is awaited before the next task. */
5
5
  export type TaskRunners = Record<Task, () => Promise<void> | void>;
6
6
  export declare class Scheduler {
@@ -5,7 +5,15 @@
5
5
  * wires them): this module decides only when each runs.
6
6
  */
7
7
  import { noTimers } from './shared.js';
8
- const TASKS = ['history', 'save', 'purge', 'retention', 'forward', 'expiry'];
8
+ const TASKS = [
9
+ 'history',
10
+ 'save',
11
+ 'reseed',
12
+ 'purge',
13
+ 'retention',
14
+ 'forward',
15
+ 'expiry'
16
+ ];
9
17
  export class Scheduler {
10
18
  room;
11
19
  tasks;
@@ -56,5 +56,15 @@ export declare const noTimers: <T>(fn: () => T) => T;
56
56
  export declare const DEFAULT_MAX_COMMENT_BYTES: number;
57
57
  /** Comment requests per second and user (or socket), by default. */
58
58
  export declare const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
59
+ /** How long nobody may write before a re-seed runs, by default (ten minutes, `room.reseed.timing`). */
60
+ export declare const DEFAULT_RESEED_IDLE: number;
61
+ /** The share of the stored bytes a re-seed must give back, by default. */
62
+ export declare const DEFAULT_RESEED_GAIN = 0.4;
63
+ /** The share of the document quota past which a re-seed waits for no idle period, by default. */
64
+ export declare const DEFAULT_RESEED_FORCE = 0.8;
65
+ /** Days a re-seed's cut is kept, so an offline client's edits still merge, by default (`room.reseed.grace`). */
66
+ export declare const DEFAULT_RESEED_GRACE_DAYS = 30;
59
67
  /** The tag of the document's sockets: other sockets of the object are left to you. */
60
68
  export declare const SOCKET_TAG = "edytor";
69
+ /** A socket of an older epoch (`room.reseed.protocol`), tagged too: it hands over its old document, nothing is relayed to it. */
70
+ export declare const STALE_TAG = "edytor-stale";
@@ -75,5 +75,15 @@ export const noTimers = (fn) => {
75
75
  export const DEFAULT_MAX_COMMENT_BYTES = 4 * 1024 * 1024;
76
76
  /** Comment requests per second and user (or socket), by default. */
77
77
  export const DEFAULT_MAX_COMMENT_REQUESTS_PER_SECOND = 2;
78
+ /** How long nobody may write before a re-seed runs, by default (ten minutes, `room.reseed.timing`). */
79
+ export const DEFAULT_RESEED_IDLE = 10 * 60_000;
80
+ /** The share of the stored bytes a re-seed must give back, by default. */
81
+ export const DEFAULT_RESEED_GAIN = 0.4;
82
+ /** The share of the document quota past which a re-seed waits for no idle period, by default. */
83
+ export const DEFAULT_RESEED_FORCE = 0.8;
84
+ /** Days a re-seed's cut is kept, so an offline client's edits still merge, by default (`room.reseed.grace`). */
85
+ export const DEFAULT_RESEED_GRACE_DAYS = 30;
78
86
  /** The tag of the document's sockets: other sockets of the object are left to you. */
79
87
  export const SOCKET_TAG = 'edytor';
88
+ /** A socket of an older epoch (`room.reseed.protocol`), tagged too: it hands over its old document, nothing is relayed to it. */
89
+ export const STALE_TAG = 'edytor-stale';
@@ -83,6 +83,13 @@ export declare class RoomStorage {
83
83
  compact(): {
84
84
  rows: number;
85
85
  };
86
+ /**
87
+ * A re-seed (`room.reseed.cut`): `update`, the new epoch, replaces every
88
+ * row (the generation record and one snapshot), in one storage
89
+ * transaction with `within` (the re-seed's own rows). The live document
90
+ * is not touched: the caller rebuilds it from the rows.
91
+ */
92
+ replaceWith(update: Uint8Array, within: () => void): void;
86
93
  /**
87
94
  * Automatic compaction, once the message that made it due is
88
95
  * acknowledged. A failure is logged and retried after half the
@@ -212,7 +212,8 @@ export class RoomStorage {
212
212
  tables.editors,
213
213
  tables.restore,
214
214
  tables.moves,
215
- tables.late
215
+ tables.late,
216
+ tables.cuts
216
217
  ])
217
218
  sql.exec(`DROP TABLE IF EXISTS ${table}`);
218
219
  });
@@ -523,6 +524,36 @@ export class RoomStorage {
523
524
  return { rows };
524
525
  });
525
526
  }
527
+ /**
528
+ * A re-seed (`room.reseed.cut`): `update`, the new epoch, replaces every
529
+ * row (the generation record and one snapshot), in one storage
530
+ * transaction with `within` (the re-seed's own rows). The live document
531
+ * is not touched: the caller rebuilds it from the rows.
532
+ */
533
+ replaceWith(update, within) {
534
+ const room = this.room;
535
+ const measured = this.documentBytes;
536
+ let record = -1;
537
+ try {
538
+ room.ctx.storage.transactionSync(() => {
539
+ room.sql.exec(`DELETE FROM ${room.tables.rows}`);
540
+ this.documentBytes = 0;
541
+ // Record numbers keep growing, as at compaction: a compression of
542
+ // an earlier snapshot never lands on this one.
543
+ this.insert('generation', encodeJSON(GENERATION_RECORD));
544
+ record = this.insert('snapshot', update);
545
+ this.touch();
546
+ within();
547
+ });
548
+ }
549
+ catch (error) {
550
+ this.documentBytes = measured;
551
+ throw error;
552
+ }
553
+ this.updates = 0;
554
+ this.storedWaiting = Y.createIdSet();
555
+ this.#compressLater(record, update);
556
+ }
526
557
  /**
527
558
  * Automatic compaction, once the message that made it due is
528
559
  * acknowledged. A failure is logged and retried after half the
@@ -691,6 +722,7 @@ export class RoomStorage {
691
722
  noteChange(origin) {
692
723
  const at = this.room.clock();
693
724
  this.room.purge.noteChange(at);
725
+ this.room.reseed.noteChange(at);
694
726
  this.room.history.noteChange(origin, at);
695
727
  }
696
728
  // ── The `onSave` mirror ─────────────────────────────────────────────