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.
- package/README.md +3 -3
- package/dist/cloudflare/DocumentRoom.d.ts +77 -2
- package/dist/cloudflare/DocumentRoom.js +37 -1
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/room/access.d.ts +6 -1
- package/dist/cloudflare/room/access.js +30 -4
- package/dist/cloudflare/room/admission.d.ts +11 -0
- package/dist/cloudflare/room/admission.js +43 -10
- package/dist/cloudflare/room/context.d.ts +73 -76
- package/dist/cloudflare/room/context.js +17 -6
- package/dist/cloudflare/room/forged.d.ts +1 -1
- package/dist/cloudflare/room/forged.js +11 -9
- package/dist/cloudflare/room/frames.d.ts +2 -0
- package/dist/cloudflare/room/frames.js +2 -0
- package/dist/cloudflare/room/presence.js +1 -4
- package/dist/cloudflare/room/reseed.d.ts +93 -0
- package/dist/cloudflare/room/reseed.js +500 -0
- package/dist/cloudflare/room/scheduler.d.ts +1 -1
- package/dist/cloudflare/room/scheduler.js +9 -1
- package/dist/cloudflare/room/shared.d.ts +10 -0
- package/dist/cloudflare/room/shared.js +10 -0
- package/dist/cloudflare/room/storage.d.ts +7 -0
- package/dist/cloudflare/room/storage.js +33 -1
- package/dist/components/Edytor.svelte +18 -3
- package/dist/components/Edytor.svelte.d.ts +10 -1
- package/dist/components/EdytorRoot.svelte +17 -0
- package/dist/components/EdytorRoot.svelte.d.ts +4 -0
- package/dist/components/Suggestion.svelte +1 -1
- package/dist/crdt/attribution/block.d.ts +3 -0
- package/dist/crdt/attribution/block.js +3 -2
- package/dist/crdt/doc/history.js +2 -3
- package/dist/crdt/doc/ops.d.ts +81 -0
- package/dist/crdt/doc/ops.js +1898 -0
- package/dist/crdt/doc/suggest.d.ts +6 -2
- package/dist/crdt/doc/suggest.js +6 -5
- package/dist/crdt/edytor-doc.d.ts +157 -157
- package/dist/crdt/edytor-doc.js +15 -43
- package/dist/crdt/engine-room.d.ts +4 -0
- package/dist/crdt/engine-room.js +6 -0
- package/dist/crdt/flow.d.ts +4 -46
- package/dist/crdt/flow.js +2 -262
- package/dist/crdt/index.d.ts +64 -75
- package/dist/crdt/operations.d.ts +1 -1
- package/dist/crdt/placement/resolve.d.ts +1 -1
- package/dist/crdt/protocol.d.ts +2 -2
- package/dist/crdt/protocol.js +2 -2
- package/dist/crdt/protocols/auth.d.ts +8 -1
- package/dist/crdt/protocols/auth.js +14 -1
- package/dist/crdt/protocols/awareness.js +46 -81
- package/dist/crdt/providers/index.d.ts +11 -22
- package/dist/crdt/providers/index.js +22 -3
- package/dist/crdt/providers/indexeddb.d.ts +6 -10
- package/dist/crdt/providers/indexeddb.js +10 -16
- package/dist/crdt/providers/room.d.ts +26 -0
- package/dist/crdt/providers/room.js +41 -12
- package/dist/crdt/providers/websocket.d.ts +10 -14
- package/dist/crdt/providers/websocket.js +43 -23
- package/dist/crdt/rangeDelete.d.ts +2 -47
- package/dist/crdt/rangeDelete.js +1 -169
- package/dist/crdt/reseed.d.ts +127 -0
- package/dist/crdt/reseed.js +533 -0
- package/dist/crdt/restore.d.ts +13 -1
- package/dist/crdt/restore.js +8 -3
- package/dist/crdt/schema.d.ts +6 -0
- package/dist/crdt/schema.js +6 -0
- package/dist/crdt/structs.d.ts +8 -11
- package/dist/crdt/structs.js +25 -44
- package/dist/crdt/text/index/build.d.ts +119 -0
- package/dist/crdt/text/index/build.js +2801 -0
- package/dist/crdt/text/index/shared.d.ts +2 -6
- package/dist/crdt/text/index/shared.js +2 -5
- package/dist/crdt/text/model.js +2 -3
- package/dist/crdt/text/runs.d.ts +9 -12
- package/dist/crdt/text/runs.js +2 -165
- package/dist/crdt/vendor/yjs/UPSTREAM.md +101 -5
- package/dist/crdt/vendor/yjs/dts/index.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/utils/Transaction.d.ts +10 -10
- package/dist/crdt/vendor/yjs/dts/utils/encoding.d.ts +1 -0
- package/dist/crdt/vendor/yjs/src/index.js +1 -1
- package/dist/crdt/vendor/yjs/src/structs/Item.js +24 -13
- package/dist/crdt/vendor/yjs/src/utils/Transaction.js +12 -10
- package/dist/crdt/vendor/yjs/src/utils/encoding.js +42 -1
- package/dist/events/domTextOffset.d.ts +2 -0
- package/dist/events/domTextOffset.js +8 -0
- package/dist/export/forms.d.ts +50 -50
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/richtext/RichTextPlugin.svelte +63 -61
- package/dist/plugins/richtext/richTextOperations.js +26 -32
- package/dist/selection/selection.svelte.d.ts +1 -1
- package/dist/selection/selection.svelte.js +87 -106
- package/dist/selection/selection.utils.js +5 -8
- package/dist/session/commands.js +49 -51
- package/dist/session/handles.js +3 -7
- package/dist/surface/attributes.d.ts +3 -3
- package/dist/surface/attributes.js +2 -2
- package/dist/surface/observer.svelte.d.ts +7 -7
- package/dist/surface/observer.svelte.js +225 -336
- package/dist/surface/projector.svelte.js +2 -2
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +7 -0
- package/package.json +1 -1
- package/dist/crdt/doc/capability.d.ts +0 -56
- package/dist/crdt/doc/capability.js +0 -207
- package/dist/crdt/doc/content.d.ts +0 -16
- package/dist/crdt/doc/content.js +0 -77
- package/dist/crdt/doc/delete.d.ts +0 -15
- package/dist/crdt/doc/delete.js +0 -84
- package/dist/crdt/doc/layout.d.ts +0 -9
- package/dist/crdt/doc/layout.js +0 -178
- package/dist/crdt/doc/meta.d.ts +0 -19
- package/dist/crdt/doc/meta.js +0 -198
- package/dist/crdt/doc/moves.d.ts +0 -21
- package/dist/crdt/doc/moves.js +0 -274
- package/dist/crdt/doc/split-merge.d.ts +0 -10
- package/dist/crdt/doc/split-merge.js +0 -179
- package/dist/crdt/doc/steps.d.ts +0 -65
- package/dist/crdt/doc/steps.js +0 -311
- package/dist/crdt/text/index/cache.d.ts +0 -16
- package/dist/crdt/text/index/cache.js +0 -175
- package/dist/crdt/text/index/claims.d.ts +0 -18
- package/dist/crdt/text/index/claims.js +0 -203
- package/dist/crdt/text/index/fold.d.ts +0 -21
- package/dist/crdt/text/index/fold.js +0 -656
- package/dist/crdt/text/index/layout.d.ts +0 -22
- package/dist/crdt/text/index/layout.js +0 -258
- package/dist/crdt/text/index/placement.d.ts +0 -47
- package/dist/crdt/text/index/placement.js +0 -738
- package/dist/crdt/text/index/records.d.ts +0 -13
- package/dist/crdt/text/index/records.js +0 -139
- package/dist/crdt/text/index/report.d.ts +0 -18
- package/dist/crdt/text/index/report.js +0 -337
- package/dist/crdt/text/index/state.d.ts +0 -197
- package/dist/crdt/text/index/state.js +0 -116
- package/dist/crdt/text/index/streams.d.ts +0 -25
- 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 = [
|
|
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 ─────────────────────────────────────────────
|