js-bao 0.5.2 → 0.6.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 +4 -0
- package/dist/browser.cjs +4069 -350
- package/dist/browser.d.cts +2269 -50
- package/dist/browser.d.ts +2269 -50
- package/dist/browser.js +4070 -351
- package/dist/client.cjs +343 -2
- package/dist/client.d.cts +739 -12
- package/dist/client.d.ts +739 -12
- package/dist/client.js +340 -1
- package/dist/cloudflare-do.cjs +847 -6
- package/dist/cloudflare-do.d.cts +1085 -13
- package/dist/cloudflare-do.d.ts +1085 -13
- package/dist/cloudflare-do.js +845 -5
- package/dist/cloudflare.cjs +359 -10
- package/dist/cloudflare.d.cts +751 -13
- package/dist/cloudflare.d.ts +751 -13
- package/dist/cloudflare.js +356 -9
- package/dist/codegen-v2.cjs +319 -3
- package/dist/codegen.cjs +1 -1
- package/dist/index.cjs +4325 -501
- package/dist/index.d.cts +2253 -18
- package/dist/index.d.ts +2253 -18
- package/dist/index.js +4238 -470
- package/dist/node.cjs +4167 -359
- package/dist/node.d.cts +2280 -58
- package/dist/node.d.ts +2280 -58
- package/dist/node.js +4117 -361
- package/package.json +1 -1
package/dist/browser.d.cts
CHANGED
|
@@ -82,6 +82,532 @@ interface Schema {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Shared format-2 ("large document", #2816) primitives.
|
|
87
|
+
*
|
|
88
|
+
* A format-2 document is no longer "one Y.Doc": the authoritative document is
|
|
89
|
+
* a persisted SQLite `records` table on both ends, and the Y.Doc shrinks to a
|
|
90
|
+
* bounded per-epoch **state overlay** of the records/fields touched during the
|
|
91
|
+
* current epoch. This module owns the overlay's on-the-wire shape so the
|
|
92
|
+
* client write points, the client observer, the server projector, the
|
|
93
|
+
* materializer and the snapshot builder all encode and decode it identically.
|
|
94
|
+
*
|
|
95
|
+
* ## Encoding — flat field-level keys (sponsor amendment, 2026-08-21)
|
|
96
|
+
*
|
|
97
|
+
* Each model map holds FLAT entries, not one nested `Y.Map` per record:
|
|
98
|
+
*
|
|
99
|
+
* ```
|
|
100
|
+
* ⟨recordId⟩/⟨field⟩ → value ordinary field (`null` = unset)
|
|
101
|
+
* ⟨recordId⟩/⟨field⟩/⟨member⟩ → true|false stringset member / tombstone (D3)
|
|
102
|
+
* ⟨recordId⟩/_replace → true discard the base row
|
|
103
|
+
* ⟨recordId⟩/_deleted → true|false record tombstone
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* The nested layout cannot work under epochs: two clients that both touch a
|
|
107
|
+
* record ABSENT from the current epoch each mint a fresh `Y.Map` at the same
|
|
108
|
+
* model-map key, and Yjs resolves that parent-key conflict as last-writer-wins,
|
|
109
|
+
* silently dropping the loser's fields. With flat keys, concurrency resolves
|
|
110
|
+
* per field key, so a concurrent create-vs-patch merges to the union of fields
|
|
111
|
+
* with LWW only on genuinely colliding fields.
|
|
112
|
+
*
|
|
113
|
+
* The record's LOGICAL overlay content is unchanged from the approved design —
|
|
114
|
+
* consumers group a record's keys back into a fields object plus markers via
|
|
115
|
+
* {@link groupOverlayEntries} and apply the record-level rules in
|
|
116
|
+
* {@link materializeOverlayRecord}.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/** Marker key: the create discards the base row and writes exactly its fields. */
|
|
120
|
+
declare const OVERLAY_MARKER_REPLACE = "_replace";
|
|
121
|
+
/** Marker key: the record is deleted (a bare Yjs key delete is invisible). */
|
|
122
|
+
declare const OVERLAY_MARKER_DELETED = "_deleted";
|
|
123
|
+
/** Overlay key for an ordinary field of a record. */
|
|
124
|
+
declare function overlayFieldKey(recordId: string, field: string): string;
|
|
125
|
+
/** Overlay key for one stringset member of a record's field. */
|
|
126
|
+
declare function overlayMemberKey(recordId: string, field: string, member: string): string;
|
|
127
|
+
/** Overlay key for a record-level marker (written unescaped, by construction). */
|
|
128
|
+
declare function overlayMarkerKey(recordId: string, marker: string): string;
|
|
129
|
+
/** The parts an overlay key decodes to, or `null` when it is not an overlay key. */
|
|
130
|
+
interface ParsedOverlayKey {
|
|
131
|
+
recordId: string;
|
|
132
|
+
field?: string;
|
|
133
|
+
member?: string;
|
|
134
|
+
marker?: string;
|
|
135
|
+
}
|
|
136
|
+
/** Decode an overlay key back into its parts; `null` when it is malformed. */
|
|
137
|
+
declare function parseOverlayKey(key: string): ParsedOverlayKey | null;
|
|
138
|
+
/** One record's overlay content, regrouped from its flat keys. */
|
|
139
|
+
interface OverlayRecordEntry {
|
|
140
|
+
id: string;
|
|
141
|
+
/** Touched ordinary fields. A `null` value is an explicit unset (RFC 7396). */
|
|
142
|
+
fields: Record<string, unknown>;
|
|
143
|
+
/** Touched stringset members per field: `member → true` add, `false` tombstone. */
|
|
144
|
+
stringSets: Record<string, Record<string, boolean>>;
|
|
145
|
+
/** The create discarded the base row. */
|
|
146
|
+
replace: boolean;
|
|
147
|
+
/** The record is tombstoned. */
|
|
148
|
+
deleted: boolean;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Regroup a model map's flat overlay entries into one {@link OverlayRecordEntry}
|
|
152
|
+
* per record. Accepts anything iterable of `[key, value]` — a `Y.Map`'s
|
|
153
|
+
* `entries()`, an array of observed changes, or a decoded snapshot.
|
|
154
|
+
*/
|
|
155
|
+
declare function groupOverlayEntries(entries: Iterable<readonly [string, unknown]>): Map<string, OverlayRecordEntry>;
|
|
156
|
+
/** Anything that can enumerate a model map's overlay keys. */
|
|
157
|
+
interface OverlayEntrySource {
|
|
158
|
+
entries(): Iterable<readonly [string, unknown]> | IterableIterator<any>;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Read ONE record's complete overlay entry out of a model map.
|
|
162
|
+
*
|
|
163
|
+
* A delta fold — projecting exactly the keys an update touched — is right for
|
|
164
|
+
* a patch, which merges over the stored row. It is wrong for `_replace`: that
|
|
165
|
+
* marker discards the row entirely, so the record has to be rebuilt from every
|
|
166
|
+
* overlay key it currently has, not just the ones the arriving update carried.
|
|
167
|
+
* Concurrency is where the two differ: when one client patches a record while
|
|
168
|
+
* another deletes and re-creates it, Yjs merges their keys per key, and the
|
|
169
|
+
* update carrying `_replace` names only its own — folding it alone would drop
|
|
170
|
+
* the concurrent patch that the overlay itself still holds.
|
|
171
|
+
*
|
|
172
|
+
* Costs a walk of the model map, which is bounded by the epoch rotation
|
|
173
|
+
* threshold. Use {@link completeOverlayEntries} when several records need
|
|
174
|
+
* rebuilding: it pays that walk ONCE for all of them.
|
|
175
|
+
*/
|
|
176
|
+
declare function readOverlayRecord(modelMap: OverlayEntrySource, recordId: string): OverlayRecordEntry;
|
|
177
|
+
/**
|
|
178
|
+
* Complete the entries of a delta fold that need the record's whole overlay.
|
|
179
|
+
*
|
|
180
|
+
* Applied by both the server's projector and the client's observer, so the
|
|
181
|
+
* authoritative table and the local merged view fold an update identically.
|
|
182
|
+
*
|
|
183
|
+
* Every create carries `_replace`, so this sits on the hot write path: the
|
|
184
|
+
* model map is walked ONCE per call however many records were replaced. A walk
|
|
185
|
+
* per replaced record would make one bulk create quadratic in the number of
|
|
186
|
+
* records it writes.
|
|
187
|
+
*/
|
|
188
|
+
declare function completeOverlayEntries(modelMap: OverlayEntrySource, entries: Map<string, OverlayRecordEntry>): Map<string, OverlayRecordEntry>;
|
|
189
|
+
/**
|
|
190
|
+
* Materialize one record: `base ⊕ overlay`, the operation that turns
|
|
191
|
+
* `S_E ⊕ D_E` into the authoritative `records` row.
|
|
192
|
+
*
|
|
193
|
+
* - a `_deleted` tombstone materializes to `null` (delete the row);
|
|
194
|
+
* - `_replace` discards `base` entirely and writes exactly the overlay's
|
|
195
|
+
* fields, so a delete-then-re-create cannot resurrect base fields the
|
|
196
|
+
* re-created record omitted;
|
|
197
|
+
* - an ordinary field merges RFC 7396-style, with an explicit `null` removing
|
|
198
|
+
* the key;
|
|
199
|
+
* - stringset member keys apply as add/remove deltas over the base members.
|
|
200
|
+
*
|
|
201
|
+
* Applying an overlay is idempotent and order-free at the record level, so a
|
|
202
|
+
* torn read during a snapshot build is corrected by the next overlay.
|
|
203
|
+
*/
|
|
204
|
+
declare function materializeOverlayRecord(base: Record<string, unknown> | null | undefined, overlay: OverlayRecordEntry): Record<string, unknown> | null;
|
|
205
|
+
/** A record mutation expressed in overlay terms, before it is encoded to keys. */
|
|
206
|
+
interface OverlayMutation {
|
|
207
|
+
id: string;
|
|
208
|
+
/**
|
|
209
|
+
* `create` writes `_replace` (the id is absent from the merged view) and
|
|
210
|
+
* clears any prior `_deleted`; `patch` touches only the named fields;
|
|
211
|
+
* `delete` writes the `_deleted` tombstone.
|
|
212
|
+
*/
|
|
213
|
+
kind: "create" | "patch" | "delete";
|
|
214
|
+
/** Ordinary field values; `null` is an explicit unset. */
|
|
215
|
+
fields?: Record<string, unknown>;
|
|
216
|
+
/** Per-field stringset deltas: `member → true` add, `false` remove (D3). */
|
|
217
|
+
stringSetDeltas?: Record<string, Record<string, boolean>>;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Encode a mutation into the flat overlay entries it writes. Returned as
|
|
221
|
+
* `[key, value]` pairs so callers can apply them to a `Y.Map`, ship them, or
|
|
222
|
+
* assert on them without a Y.Doc.
|
|
223
|
+
*/
|
|
224
|
+
declare function encodeOverlayMutation(mutation: OverlayMutation): Array<[string, unknown]>;
|
|
225
|
+
/** Apply {@link encodeOverlayMutation}'s entries to a model map in one place. */
|
|
226
|
+
declare function applyOverlayMutation(modelMap: Y.Map<any>, mutation: OverlayMutation): void;
|
|
227
|
+
/**
|
|
228
|
+
* A composite (`Y.Map` / `Y.Array` / `Y.Text`) value written into a field slot
|
|
229
|
+
* of a format-2 document (D4). Format 2 rejects these instead of normalizing
|
|
230
|
+
* them, which is what lets the overlay encode field values as plain JSON.
|
|
231
|
+
*/
|
|
232
|
+
declare class Format2CompositeFieldError extends Error {
|
|
233
|
+
readonly modelName: string;
|
|
234
|
+
readonly recordId: string;
|
|
235
|
+
readonly field: string;
|
|
236
|
+
constructor(modelName: string, recordId: string, field: string);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* D4: reject a Yjs composite in a field slot on a format-2 write path. Plain
|
|
240
|
+
* values pass untouched, including stringset arrays and `{$add}`/`{$remove}`
|
|
241
|
+
* delta objects — only Yjs instances are rejected.
|
|
242
|
+
*/
|
|
243
|
+
declare function assertNoFormat2CompositeFields(modelName: string, recordId: string, fields: Record<string, unknown> | undefined | null): void;
|
|
244
|
+
/**
|
|
245
|
+
* D3, format-scoped: the tombstone-aware stringset discriminator. A format-2
|
|
246
|
+
* stringset `Y.Map` may carry `false` for a removed member, which the legacy
|
|
247
|
+
* (#625) discriminator deliberately rejects — widening it globally would
|
|
248
|
+
* reclassify legacy all-boolean composites and break the "format 1 unchanged"
|
|
249
|
+
* contract, so legacy paths keep {@link isStringSetYMap} untouched.
|
|
250
|
+
*
|
|
251
|
+
* Safe here because D4 guarantees no composite field values exist on format-2
|
|
252
|
+
* documents.
|
|
253
|
+
*/
|
|
254
|
+
declare function isFormat2StringSetYMap(value: unknown): value is Y.Map<any>;
|
|
255
|
+
/** Members of a tombstone-aware stringset map: the `true` entries only. */
|
|
256
|
+
declare function format2StringSetMembers(value: unknown): string[];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Fold a format-2 epoch overlay into a `records` table (#2816).
|
|
260
|
+
*
|
|
261
|
+
* Shared by BOTH ends: the Durable Object's authoritative table and the
|
|
262
|
+
* client's local merged view fold the overlay with this one implementation, so
|
|
263
|
+
* "what the server says the record is" and "what the client reads back" cannot
|
|
264
|
+
* drift.
|
|
265
|
+
*
|
|
266
|
+
* On a format-2 document the SQLite `records` table is the document — the
|
|
267
|
+
* Y.Doc holds only the current epoch's overlay. Every applied update therefore
|
|
268
|
+
* has to reach `records` in the same transaction boundary as the update-log
|
|
269
|
+
* row, for a document that may be 2GB. That rules out read-modify-write of the
|
|
270
|
+
* record: this module expresses each overlay entry as one merge-upsert whose
|
|
271
|
+
* RFC 7396 semantics SQLite's `json_patch` already implements (an explicit
|
|
272
|
+
* `null` deletes the key), plus incremental `stringset_index` maintenance for
|
|
273
|
+
* the member deltas.
|
|
274
|
+
*
|
|
275
|
+
* The semantics are pinned against the shared JS materializer
|
|
276
|
+
* (`materializeOverlayRecord`), which is what the client's merged view folds
|
|
277
|
+
* with — the two must never drift.
|
|
278
|
+
*/
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The slice of Cloudflare's `SqlStorage` this module needs. `exec` returns an
|
|
282
|
+
* iterable cursor of rows; a plain array satisfies it too, which is what the
|
|
283
|
+
* hermetic tests bind.
|
|
284
|
+
*/
|
|
285
|
+
interface OverlaySql {
|
|
286
|
+
exec(query: string, ...bindings: unknown[]): Iterable<Record<string, any>>;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Which tables the fold writes to.
|
|
290
|
+
*
|
|
291
|
+
* The Durable Object hosts exactly one document, so it uses the default names.
|
|
292
|
+
* A CLIENT database can hold several connected documents at once, and the
|
|
293
|
+
* merged view of one must never be readable as another's — so the client
|
|
294
|
+
* passes per-document table names (see `format2TableNames`). The STATEMENTS
|
|
295
|
+
* stay this module's single implementation either way; only the table names
|
|
296
|
+
* differ, which is what keeps the two ends from drifting.
|
|
297
|
+
*/
|
|
298
|
+
interface OverlayTables {
|
|
299
|
+
records: string;
|
|
300
|
+
stringSetIndex: string;
|
|
301
|
+
}
|
|
302
|
+
/** The Durable Object's tables: one document per DO, so no scoping needed. */
|
|
303
|
+
declare const DEFAULT_OVERLAY_TABLES: OverlayTables;
|
|
304
|
+
/**
|
|
305
|
+
* Project one record's overlay entry into `records` + `stringset_index`.
|
|
306
|
+
*
|
|
307
|
+
* Callers pass the entry for the keys the update TOUCHED, not the record's
|
|
308
|
+
* whole overlay: materialization is a fold, so `row ⊕ delta` over the stored
|
|
309
|
+
* row is the same record as replaying the epoch from its base.
|
|
310
|
+
*/
|
|
311
|
+
declare function projectOverlayEntry(sql: OverlaySql, modelName: string, entry: OverlayRecordEntry, tables?: OverlayTables): void;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The client-side record store for format-2 (large) documents (#2816).
|
|
315
|
+
*
|
|
316
|
+
* A format-2 client no longer reads records out of Y.Maps: the epoch doc holds
|
|
317
|
+
* only what the current epoch touched, so the merged `records` view is the
|
|
318
|
+
* document. The existing `StorageProvider` cannot serve that — it is an async
|
|
319
|
+
* key-value API with no query and no multi-table transaction.
|
|
320
|
+
*
|
|
321
|
+
* This store defines the contract instead:
|
|
322
|
+
*
|
|
323
|
+
* - **reads** come from `records` (folded with the same `projectOverlayEntry`
|
|
324
|
+
* the Durable Object uses, so the two views cannot drift);
|
|
325
|
+
* - **writes** commit the merged row and the `_pending_ops` entry in ONE
|
|
326
|
+
* transaction — the commit barrier a `save()` resolves behind, which is what
|
|
327
|
+
* makes read-your-writes true and makes a crash immediately after the save
|
|
328
|
+
* non-lossy;
|
|
329
|
+
* - **`_pending_ops`** are pruned only at or below the server's `update.ack`
|
|
330
|
+
* high-water mark, never on `ws.send`;
|
|
331
|
+
* - `_epoch` and `_snapshot_load` carry the epoch the client is following and
|
|
332
|
+
* the chunks a cold load has already completed.
|
|
333
|
+
*
|
|
334
|
+
* It is deliberately synchronous over an injected SQL executor: better-sqlite3
|
|
335
|
+
* (Node) drives it directly, and the worker-hosted OPFS engine drives it inside
|
|
336
|
+
* its worker, where a single round-trip commits the whole transaction.
|
|
337
|
+
*
|
|
338
|
+
* ## Document scoping
|
|
339
|
+
*
|
|
340
|
+
* Unlike the Durable Object — one document per DO — a client database can hold
|
|
341
|
+
* several connected documents at once, so every table here is scoped to ONE
|
|
342
|
+
* document: `records`/`stringset_index` get per-document names, and the
|
|
343
|
+
* bookkeeping tables carry a `doc_id` column in their primary key. Without
|
|
344
|
+
* that, two open large documents would read each other's records and one
|
|
345
|
+
* document's `update.ack` would prune the other's unsynced writes.
|
|
346
|
+
*/
|
|
347
|
+
|
|
348
|
+
/** A SQL executor that can also run a set of statements as one atomic commit. */
|
|
349
|
+
interface Format2StoreSql extends OverlaySql {
|
|
350
|
+
transaction<T>(fn: () => T): T;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Per-document names for the merged view's tables.
|
|
354
|
+
*
|
|
355
|
+
* The document id is hex-encoded rather than sanitized: a lossy mapping could
|
|
356
|
+
* land two documents on the same tables, which is exactly the mixing this
|
|
357
|
+
* scoping exists to prevent.
|
|
358
|
+
*/
|
|
359
|
+
declare function format2TableNames(docId: string): OverlayTables;
|
|
360
|
+
/**
|
|
361
|
+
* The local schema one format-2 document owns.
|
|
362
|
+
*
|
|
363
|
+
* `records`/`stringset_index` keep the DO's column shape and primary keys —
|
|
364
|
+
* the shared fold in `overlaySql.ts` writes both ends — under per-document
|
|
365
|
+
* names. The bookkeeping tables are shared across documents and scoped by
|
|
366
|
+
* `doc_id` instead.
|
|
367
|
+
*/
|
|
368
|
+
declare function format2ClientDDL(docId: string): string[];
|
|
369
|
+
/** One durable record of a local write, replayed if it is never acked. */
|
|
370
|
+
interface PendingOp {
|
|
371
|
+
seq: number;
|
|
372
|
+
model: string;
|
|
373
|
+
recordId: string;
|
|
374
|
+
op: "create" | "patch" | "delete";
|
|
375
|
+
/** Field names the op touched — the unit amended-D1 conflict detection works on. */
|
|
376
|
+
fields: string[];
|
|
377
|
+
/** Epoch the op was written against, for recency-aware replay. */
|
|
378
|
+
baseEpoch: number;
|
|
379
|
+
/** Server-offset-corrected wall clock at write time. */
|
|
380
|
+
ts: number;
|
|
381
|
+
/**
|
|
382
|
+
* The overlay mutation this op wrote, so the write can be reproduced from
|
|
383
|
+
* the durable log alone — see {@link Format2RecordStore.commitLocalWrite}.
|
|
384
|
+
* `null` only for a row written by a build that did not record it.
|
|
385
|
+
*/
|
|
386
|
+
mutation: OverlayMutation | null;
|
|
387
|
+
}
|
|
388
|
+
type PendingOpInput = Omit<PendingOp, "seq" | "mutation"> & {
|
|
389
|
+
seq?: number;
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* A model this device did not hydrate, used as if it had (#2816,
|
|
393
|
+
* Precondition B's fallback).
|
|
394
|
+
*
|
|
395
|
+
* A capped load leaves a model out because the device has no room for it. The
|
|
396
|
+
* records it does hold of that model afterwards — whatever a later overlay
|
|
397
|
+
* happens to touch — are not the model, they are a handful of recently
|
|
398
|
+
* changed rows, and answering a query from them would be wrong in the way
|
|
399
|
+
* that is hardest to notice. So the store refuses instead: the model is
|
|
400
|
+
* UNAVAILABLE on this device, which is a thing an app can handle, rather than
|
|
401
|
+
* nearly empty, which is not.
|
|
402
|
+
*/
|
|
403
|
+
declare class Format2ModelNotHydratedError extends Error {
|
|
404
|
+
readonly model: string;
|
|
405
|
+
/** The models this device did load. */
|
|
406
|
+
readonly hydrated: string[];
|
|
407
|
+
/** Stable across builds, so an app can branch on it rather than on text. */
|
|
408
|
+
readonly code = "FORMAT2_MODEL_NOT_HYDRATED";
|
|
409
|
+
constructor(model: string,
|
|
410
|
+
/** The models this device did load. */
|
|
411
|
+
hydrated: string[]);
|
|
412
|
+
}
|
|
413
|
+
declare class Format2RecordStore {
|
|
414
|
+
private readonly sql;
|
|
415
|
+
private readonly docId;
|
|
416
|
+
/** This document's merged-view tables (never another document's). */
|
|
417
|
+
private readonly tables;
|
|
418
|
+
/** Models this device holds, or `null` for the whole document. */
|
|
419
|
+
private scope;
|
|
420
|
+
/** Whether {@link scope} has been read from `_epoch` yet. */
|
|
421
|
+
private scopeLoaded;
|
|
422
|
+
constructor(sql: Format2StoreSql, docId: string);
|
|
423
|
+
/** Create the local schema. Safe to call on every open. */
|
|
424
|
+
init(): void;
|
|
425
|
+
/**
|
|
426
|
+
* Record which models this device holds (#2816, Precondition B's fallback).
|
|
427
|
+
*
|
|
428
|
+
* `null` — the normal case — is "the whole document". A list is what a
|
|
429
|
+
* capped cold load leaves behind: the plan
|
|
430
|
+
* {@link planSnapshotHydration} made, persisted so it outlives the load. It
|
|
431
|
+
* has to be durable, because a restart reconnects and starts folding the
|
|
432
|
+
* epoch overlay again, and without the scope that fold would quietly
|
|
433
|
+
* repopulate a skipped model with the handful of records the epoch touched.
|
|
434
|
+
*/
|
|
435
|
+
setHydrationScope(models: readonly string[] | null): void;
|
|
436
|
+
/** The models this device holds, or `null` when it holds the document. */
|
|
437
|
+
hydrationScope(): string[] | null;
|
|
438
|
+
/** Whether this device holds `modelName` at all. */
|
|
439
|
+
isHydrated(modelName: string): boolean;
|
|
440
|
+
/** Refuse a model this device does not hold, rather than answering partly. */
|
|
441
|
+
private assertHydrated;
|
|
442
|
+
read(modelName: string, recordId: string): Record<string, any> | null;
|
|
443
|
+
readAll(modelName: string): Array<Record<string, any>>;
|
|
444
|
+
/**
|
|
445
|
+
* The id of the first record whose fields all match — the merged-view answer
|
|
446
|
+
* to `upsertOn` resolution and to an optimistic unique-constraint check. A
|
|
447
|
+
* base-only holder is found here and nowhere in the epoch doc, which is the
|
|
448
|
+
* whole point.
|
|
449
|
+
*/
|
|
450
|
+
findIdByFields(modelName: string, fields: string[], values: unknown[]): string | null;
|
|
451
|
+
findIdByField(modelName: string, field: string, value: unknown): string | null;
|
|
452
|
+
/**
|
|
453
|
+
* Every record id a model holds in the merged view, ids only.
|
|
454
|
+
*
|
|
455
|
+
* The derived query tables are rebuilt from this on connect, and a rebuild
|
|
456
|
+
* has no reason to carry each record's whole `_data` through memory on the
|
|
457
|
+
* way.
|
|
458
|
+
*/
|
|
459
|
+
recordIds(modelName: string): string[];
|
|
460
|
+
/**
|
|
461
|
+
* One PAGE of a model's record ids, in id order, after `after`.
|
|
462
|
+
*
|
|
463
|
+
* The whole-model list above is a JavaScript array of every id, which on the
|
|
464
|
+
* documents this epic exists for is millions of strings held at once —
|
|
465
|
+
* before a single row has been projected. A keyset page costs the same per
|
|
466
|
+
* row and bounds what is resident, so walking a model is a walk rather than
|
|
467
|
+
* an allocation (#2816).
|
|
468
|
+
*/
|
|
469
|
+
recordIdsAfter(modelName: string, after: string | null, limit: number): string[];
|
|
470
|
+
/**
|
|
471
|
+
* Fold an overlay entry that arrived from the server.
|
|
472
|
+
*
|
|
473
|
+
* A model outside this device's hydration scope is DROPPED here rather than
|
|
474
|
+
* folded: the device declined that model because it has no room for it, and
|
|
475
|
+
* a row of it landing anyway would turn "this model is not held here" into
|
|
476
|
+
* "this model holds one record" — a merged view that reads like the document
|
|
477
|
+
* and is not it.
|
|
478
|
+
*/
|
|
479
|
+
applyRemote(modelName: string, entry: OverlayRecordEntry): void;
|
|
480
|
+
/**
|
|
481
|
+
* Run `fn` as ONE commit. A catch-up fold of a whole overlay is a single
|
|
482
|
+
* transaction rather than one per record — the difference between one commit
|
|
483
|
+
* and thousands.
|
|
484
|
+
*/
|
|
485
|
+
transaction<T>(fn: () => T): T;
|
|
486
|
+
/**
|
|
487
|
+
* Commit a LOCAL write: the merged row, the pending op, and the OVERLAY
|
|
488
|
+
* MUTATION that op stands for all land together, or none of them does.
|
|
489
|
+
* The returned sequence is what an `update.ack` later prunes against.
|
|
490
|
+
*
|
|
491
|
+
* The mutation is stored, not just the field names it touched, because the
|
|
492
|
+
* merged row cannot reproduce it: materializing a row loses the difference
|
|
493
|
+
* between a patch and a `_replace` create, turns an explicit `null` unset
|
|
494
|
+
* into an absent key, and drops stringset member tombstones. Those shapes
|
|
495
|
+
* survive only in the overlay — and the overlay's own persistence is a Yjs
|
|
496
|
+
* update flushed asynchronously AFTER this commit, so a crash in between
|
|
497
|
+
* would otherwise leave a pending op whose write nothing could reconstruct:
|
|
498
|
+
* the client would claim its sequence on the next catch-up frame, be acked
|
|
499
|
+
* for a write it never sent, and drop it.
|
|
500
|
+
*/
|
|
501
|
+
commitLocalWrite(modelName: string, mutation: OverlayMutation, pending: PendingOpInput): number;
|
|
502
|
+
/** The next local sequence: one past the highest ever issued. */
|
|
503
|
+
nextSeq(): number;
|
|
504
|
+
/**
|
|
505
|
+
* The highest sequence this client has ever committed locally — what an
|
|
506
|
+
* outbound update frame claims, and what an `update.ack` has to reach before
|
|
507
|
+
* the document has nothing unsynced left.
|
|
508
|
+
*/
|
|
509
|
+
highestLocalSeq(): number;
|
|
510
|
+
pendingOps(): PendingOp[];
|
|
511
|
+
/**
|
|
512
|
+
* Prune at or below the server's contiguous-sequence high-water mark.
|
|
513
|
+
* A lower (out-of-order or replayed) ack never lowers the mark and never
|
|
514
|
+
* resurrects a pruned op.
|
|
515
|
+
*/
|
|
516
|
+
prunePendingOps(maxContiguousSeq: number): void;
|
|
517
|
+
ackedSeq(): number;
|
|
518
|
+
epoch(): number;
|
|
519
|
+
setEpoch(epoch: number): void;
|
|
520
|
+
lastSyncAt(): number | null;
|
|
521
|
+
setLastSyncAt(at: number): void;
|
|
522
|
+
/**
|
|
523
|
+
* The offline write window this document is held to (#2816, behavior 18),
|
|
524
|
+
* as the server last reported it — the deployment default until one has.
|
|
525
|
+
*/
|
|
526
|
+
offlineWindowDays(): number;
|
|
527
|
+
/**
|
|
528
|
+
* Record that this client was in touch with the server, and (when the
|
|
529
|
+
* server said so) the window it is held to.
|
|
530
|
+
*
|
|
531
|
+
* Persisted together because the session that enforces the window is the one
|
|
532
|
+
* that cannot ask for it: a client that starts offline reads both back out
|
|
533
|
+
* of its own database.
|
|
534
|
+
*/
|
|
535
|
+
noteSync(at: number, windowDays?: number | null): void;
|
|
536
|
+
/**
|
|
537
|
+
* Record the window without claiming a sync (#2816, behavior 18).
|
|
538
|
+
*
|
|
539
|
+
* The handshake carries the window whatever else it says, including to a
|
|
540
|
+
* client that is behind by whole epochs and cannot act on the rest of the
|
|
541
|
+
* frame. Taking the number is right there; moving the mark it is measured
|
|
542
|
+
* FROM is not — a frame is proof of contact, not proof that this client's
|
|
543
|
+
* merged view is the room's state, and a document past its window would
|
|
544
|
+
* become writable again on the strength of a handshake whose catch-up then
|
|
545
|
+
* failed.
|
|
546
|
+
*/
|
|
547
|
+
noteOfflineWindow(windowDays: number | null | undefined): void;
|
|
548
|
+
/**
|
|
549
|
+
* How far this client's clock is behind the server's, in milliseconds
|
|
550
|
+
* (#2816, behavior 17).
|
|
551
|
+
*
|
|
552
|
+
* Zero until one has been measured — which is why {@link clockOffsetKnown}
|
|
553
|
+
* exists separately: a measured zero and never having measured are the same
|
|
554
|
+
* number and very different facts, and the replay resolver refuses to call a
|
|
555
|
+
* write "clearly older" on the second.
|
|
556
|
+
*/
|
|
557
|
+
clockOffset(): number;
|
|
558
|
+
/** Whether the offset above was ever measured against a server. */
|
|
559
|
+
clockOffsetKnown(): boolean;
|
|
560
|
+
/** Record the offset measured against server time while connected. */
|
|
561
|
+
noteClockOffset(offsetMs: number): void;
|
|
562
|
+
/** Now, on the server's clock as far as this client can tell. */
|
|
563
|
+
correctedNow(now?: number): number;
|
|
564
|
+
/**
|
|
565
|
+
* Forget specific pending ops (#2816, behavior 17).
|
|
566
|
+
*
|
|
567
|
+
* The one caller is a replay whose recency resolution DROPPED them: the
|
|
568
|
+
* online write won, so the op must never be replayed — and leaving it in the
|
|
569
|
+
* log would have the next epoch move carry it anyway, and would report the
|
|
570
|
+
* document as holding unsynced changes for good. The merged row it wrote
|
|
571
|
+
* stays: it is what the client is showing, and the next update from the
|
|
572
|
+
* server corrects it.
|
|
573
|
+
*/
|
|
574
|
+
forgetPendingOps(seqs: number[]): void;
|
|
575
|
+
/**
|
|
576
|
+
* Rewrite pending ops that survived a replay resolution only in PART
|
|
577
|
+
* (#2816, behavior 17).
|
|
578
|
+
*
|
|
579
|
+
* The same caller as {@link forgetPendingOps}, for the ops it narrowed
|
|
580
|
+
* rather than dropped: a patch that touched two fields, one of which an
|
|
581
|
+
* online write clearly beat. The row must be rewritten to the surviving
|
|
582
|
+
* delta — its field list AND the mutation it stands for — because the
|
|
583
|
+
* mutation is what the restore path puts back on the overlay after a crash,
|
|
584
|
+
* and putting the whole one back would resurrect exactly the field this
|
|
585
|
+
* resolution decided against.
|
|
586
|
+
*/
|
|
587
|
+
narrowPendingOps(ops: Array<Pick<PendingOp, "seq" | "fields" | "mutation">>): void;
|
|
588
|
+
completedChunks(buildId: string): string[];
|
|
589
|
+
markChunkComplete(buildId: string, chunkKey: string): void;
|
|
590
|
+
/**
|
|
591
|
+
* Throw away a merged view that cannot be repaired (#2816, behavior 12).
|
|
592
|
+
*
|
|
593
|
+
* The one caller is a client whose sealed-overlay chain was refused: the
|
|
594
|
+
* rows it holds are the state of an epoch the room has left behind, and no
|
|
595
|
+
* available artifact turns them into the current state. It reloads from a
|
|
596
|
+
* base snapshot instead — and a snapshot is a set of rows, not a diff, so
|
|
597
|
+
* loading it over these would leave behind every record deleted since. The
|
|
598
|
+
* chunk marks go with them: a mark whose rows are gone is exactly what makes
|
|
599
|
+
* a resumed load skip a chunk and finish incomplete.
|
|
600
|
+
*
|
|
601
|
+
* What survives is what this client owes the server. Unacknowledged writes
|
|
602
|
+
* are replayed onto the epoch it lands on, and the acked high-water mark
|
|
603
|
+
* keeps their sequences from colliding with fresh ones — dropping either
|
|
604
|
+
* would lose writes the app has already been told were saved.
|
|
605
|
+
*/
|
|
606
|
+
discardMergedView(): void;
|
|
607
|
+
private rows;
|
|
608
|
+
private scalar;
|
|
609
|
+
}
|
|
610
|
+
|
|
85
611
|
interface ITransactionalDatabaseOperations {
|
|
86
612
|
insert(modelName: string, data: any): Promise<void>;
|
|
87
613
|
delete(modelName: string, id: string): Promise<void>;
|
|
@@ -137,6 +663,32 @@ declare abstract class DatabaseEngine {
|
|
|
137
663
|
*/
|
|
138
664
|
deleteByDocumentId(_modelName: string, _docId: string): Promise<void>;
|
|
139
665
|
getTableName(_modelName: string): string;
|
|
666
|
+
/**
|
|
667
|
+
* A SYNCHRONOUS SQL executor over this engine's database, used by the
|
|
668
|
+
* large-document (format 2) record store. That store commits a merged
|
|
669
|
+
* record row and its pending-operation entry as one transaction — the
|
|
670
|
+
* barrier a `save()` resolves behind — which the async `query()` API cannot
|
|
671
|
+
* express. Engines whose underlying SQLite is synchronous (better-sqlite3,
|
|
672
|
+
* SQL.js) provide it; anything else leaves it undefined and cannot host a
|
|
673
|
+
* large document.
|
|
674
|
+
*/
|
|
675
|
+
createFormat2Sql?: () => Format2StoreSql;
|
|
676
|
+
/**
|
|
677
|
+
* Whether what {@link createFormat2Sql} writes survives this process.
|
|
678
|
+
*
|
|
679
|
+
* On a large document the merged record view IS the document: the Y.Doc
|
|
680
|
+
* holds one epoch's overlay, and everything else — including a record a
|
|
681
|
+
* `save()` has already resolved, and the pending write that still owes it to
|
|
682
|
+
* the server — lives only in this store. An engine whose database is in
|
|
683
|
+
* memory therefore cannot host one: a reload does not cost a cache, it costs
|
|
684
|
+
* the document and the writes nobody has acknowledged yet.
|
|
685
|
+
*
|
|
686
|
+
* The platform is asked separately about its storage quota; this answers the
|
|
687
|
+
* half only the engine knows. An engine that does not implement it is taken
|
|
688
|
+
* at the platform's word, which is what keeps hosts with storage of their
|
|
689
|
+
* own — a worker handed a file store, a test — working unchanged.
|
|
690
|
+
*/
|
|
691
|
+
format2StorageIsDurable?: () => boolean;
|
|
140
692
|
withTransaction<T>(_callback: (operations: ITransactionalDatabaseOperations) => Promise<T>): Promise<T>;
|
|
141
693
|
}
|
|
142
694
|
|
|
@@ -204,10 +756,28 @@ declare class StringSet {
|
|
|
204
756
|
}
|
|
205
757
|
|
|
206
758
|
type DocumentPermissionHint = "read" | "read-write";
|
|
759
|
+
/**
|
|
760
|
+
* Per-document storage format (#2816). `1` (or absent) is the legacy "one
|
|
761
|
+
* Y.Doc" document; `2` is a large document whose Y.Doc holds only the current
|
|
762
|
+
* epoch's overlay and whose merged `records` table is the document.
|
|
763
|
+
*/
|
|
764
|
+
type DocumentFormat = 1 | 2;
|
|
765
|
+
interface ConnectDocumentOptions {
|
|
766
|
+
documentFormat?: DocumentFormat;
|
|
767
|
+
/**
|
|
768
|
+
* The epoch a large document is on, as the caller understands it (#2816).
|
|
769
|
+
*
|
|
770
|
+
* The record store is created following epoch 0, which the open path reads
|
|
771
|
+
* as "this database holds no document" — so a bind without the mark makes a
|
|
772
|
+
* client that has the document look like one that never did.
|
|
773
|
+
*/
|
|
774
|
+
epoch?: number;
|
|
775
|
+
}
|
|
207
776
|
interface ConnectedDocument {
|
|
208
777
|
docId: string;
|
|
209
778
|
yDoc: Y.Doc;
|
|
210
779
|
permissionHint: DocumentPermissionHint;
|
|
780
|
+
documentFormat?: DocumentFormat;
|
|
211
781
|
}
|
|
212
782
|
interface SaveOptions {
|
|
213
783
|
targetDocument?: string;
|
|
@@ -398,6 +968,176 @@ declare class DocumentQueryTranslator {
|
|
|
398
968
|
private buildDocumentClause;
|
|
399
969
|
}
|
|
400
970
|
|
|
971
|
+
interface RegisteredModelInfo {
|
|
972
|
+
class: typeof BaseModel;
|
|
973
|
+
options: ModelOptions;
|
|
974
|
+
fields: Map<string, FieldOptions>;
|
|
975
|
+
}
|
|
976
|
+
declare class ModelRegistry {
|
|
977
|
+
private static instance;
|
|
978
|
+
private models;
|
|
979
|
+
private modelOptions;
|
|
980
|
+
private activeSessionModels;
|
|
981
|
+
private dbEngine;
|
|
982
|
+
constructor();
|
|
983
|
+
static getInstance(): ModelRegistry;
|
|
984
|
+
/**
|
|
985
|
+
* A registry for ONE `initJsBao` instance (#3039).
|
|
986
|
+
*
|
|
987
|
+
* The global singleton stays the registration target for
|
|
988
|
+
* `attachAndRegisterModel` / `createModelClass`; this is the per-instance
|
|
989
|
+
* view the ORM actually resolves schemas through, so two clients that
|
|
990
|
+
* register different models under the same name no longer overwrite each
|
|
991
|
+
* other's include translation and relationship validation.
|
|
992
|
+
*
|
|
993
|
+
* With explicit `models`, only those classes are in it. With `models`
|
|
994
|
+
* undefined the legacy session semantics are kept exactly: the global
|
|
995
|
+
* registry is SNAPSHOT at init time, so a model registered afterwards is not
|
|
996
|
+
* silently adopted by an already-running instance.
|
|
997
|
+
*/
|
|
998
|
+
static createInstanceRegistry(modelClasses?: (typeof BaseModel)[]): ModelRegistry;
|
|
999
|
+
registerModel(modelClass: any, options: ModelOptions, fields: Map<string, FieldOptions>): void;
|
|
1000
|
+
getModelClass(name: string): typeof BaseModel | undefined;
|
|
1001
|
+
getModelOptions(name: string): ModelOptions | undefined;
|
|
1002
|
+
getModelInfo(modelName: string): RegisteredModelInfo | undefined;
|
|
1003
|
+
getAllRegisteredModelsInfo(): RegisteredModelInfo[];
|
|
1004
|
+
private getModelNameFromClass;
|
|
1005
|
+
setExplicitModelsForSession(modelClasses?: (typeof BaseModel)[]): void;
|
|
1006
|
+
initializeAll(yDoc: Y.Doc, dbEngine: DatabaseEngine): Promise<void>;
|
|
1007
|
+
initializeAllForDocument(yDoc: Y.Doc, dbEngine: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint,
|
|
1008
|
+
/** #2816 — `2` prepares the tables without reading the Y.Map as records. */
|
|
1009
|
+
documentFormat?: 1 | 2): Promise<void>;
|
|
1010
|
+
removeDocumentData(docId: string, dbEngine: DatabaseEngine): Promise<void>;
|
|
1011
|
+
initializeRelationships(): Promise<void>;
|
|
1012
|
+
private addPrototypeMethod;
|
|
1013
|
+
clearSessionState(): void;
|
|
1014
|
+
getActiveModels(): Map<string, typeof BaseModel>;
|
|
1015
|
+
private validateSessionModels;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The binding state one ORM instance owns (#3039).
|
|
1020
|
+
*
|
|
1021
|
+
* Everything here used to live as `BaseModel` statics, so two `initJsBao`
|
|
1022
|
+
* instances in one process shared one engine, one set of connected documents,
|
|
1023
|
+
* one set of `subscribe()` listeners and one default-document mapping. The
|
|
1024
|
+
* static surface survives as accessors that resolve the acting class's
|
|
1025
|
+
* context; the state itself lives here, one per instance.
|
|
1026
|
+
*
|
|
1027
|
+
* Deliberately imports NOTHING at runtime: `BaseModel` reaches for this module
|
|
1028
|
+
* during its own evaluation, so a value import of `ModelRegistry` (which
|
|
1029
|
+
* imports `BaseModel`) would be a cycle that bites at module-init time.
|
|
1030
|
+
*/
|
|
1031
|
+
declare class OrmContext {
|
|
1032
|
+
/** Declared before the field that reads it: static initialization is ordered. */
|
|
1033
|
+
private static instanceCounter;
|
|
1034
|
+
/**
|
|
1035
|
+
* Which instance this is, for diagnostics. A process holding several clients
|
|
1036
|
+
* needs a way to say WHICH one a warning is about, so every context takes the
|
|
1037
|
+
* next number as it is created and keeps it for its lifetime.
|
|
1038
|
+
*/
|
|
1039
|
+
readonly instanceId: number;
|
|
1040
|
+
/** The engine this instance's models read and write through. */
|
|
1041
|
+
dbEngine: DatabaseEngine | null;
|
|
1042
|
+
connectedDocuments: Map<string, {
|
|
1043
|
+
yDoc: Y.Doc;
|
|
1044
|
+
permissionHint: DocumentPermissionHint;
|
|
1045
|
+
}>;
|
|
1046
|
+
/** `docId_modelName` → the Y.Map that document holds for that model. */
|
|
1047
|
+
documentYMaps: Map<string, Y.Map<any>>;
|
|
1048
|
+
/** `subscribe()` callbacks, by model name. */
|
|
1049
|
+
listenersMap: Map<string, Set<() => void>>;
|
|
1050
|
+
modelNameToDefaultDocId: Map<string, string>;
|
|
1051
|
+
globalDefaultDocId: string | undefined;
|
|
1052
|
+
defaultDocChangedListeners: Set<(payload: {
|
|
1053
|
+
previous?: string;
|
|
1054
|
+
current?: string;
|
|
1055
|
+
}) => void>;
|
|
1056
|
+
modelDocMappingChangedListeners: Set<(payload: {
|
|
1057
|
+
modelName: string;
|
|
1058
|
+
previous?: string;
|
|
1059
|
+
current?: string;
|
|
1060
|
+
}) => void>;
|
|
1061
|
+
/** Format-2 ("large document", #2816) record stores by document id. */
|
|
1062
|
+
format2Documents: Map<string, Format2RecordStore>;
|
|
1063
|
+
/** Format-2 epoch moves in flight (#2816). */
|
|
1064
|
+
format2Transitions: Map<string, Promise<void>>;
|
|
1065
|
+
/**
|
|
1066
|
+
* Nested-Y.Map stringset fields this context has already observed. Per
|
|
1067
|
+
* context, not global: a Y.Doc reconnected to a fresh instance has to be
|
|
1068
|
+
* observed again by that instance, and a global guard would silently
|
|
1069
|
+
* suppress it.
|
|
1070
|
+
*/
|
|
1071
|
+
observedStringSetMaps: WeakSet<Y.Map<any>>;
|
|
1072
|
+
/**
|
|
1073
|
+
* The registry this instance's schema lookups use. `null` on the standing
|
|
1074
|
+
* default context, where callers fall back to the global singleton.
|
|
1075
|
+
*/
|
|
1076
|
+
modelRegistry: ModelRegistry | null;
|
|
1077
|
+
/** Model classes routed to this context. */
|
|
1078
|
+
boundModels: Set<any>;
|
|
1079
|
+
/** True once {@link destroy} has run; bound classes keep pointing here. */
|
|
1080
|
+
destroyed: boolean;
|
|
1081
|
+
/** Yjs `unobserve` thunks by owning document id. */
|
|
1082
|
+
private observerDisposers;
|
|
1083
|
+
/** Teardown the owner (the document manager) registers for `destroy()`. */
|
|
1084
|
+
private teardownHooks;
|
|
1085
|
+
private static readonly NO_DOCUMENT_KEY;
|
|
1086
|
+
/**
|
|
1087
|
+
* Record how to remove an observer this context installed. Every `observe()`
|
|
1088
|
+
* in the ORM pairs with one of these: without it a torn-down instance's
|
|
1089
|
+
* callbacks keep firing on a Y.Doc that outlives it.
|
|
1090
|
+
*/
|
|
1091
|
+
registerObserverDisposer(docId: string | null | undefined, dispose: () => void): void;
|
|
1092
|
+
/** Remove every observer this context installed for `docId`. */
|
|
1093
|
+
disposeObserversForDocument(docId: string): void;
|
|
1094
|
+
private disposeAllObservers;
|
|
1095
|
+
/** Run `hook` as part of `destroy()` (the format-2 binding registers here). */
|
|
1096
|
+
onDestroy(hook: () => void | Promise<void>): void;
|
|
1097
|
+
/** The record store for `docId`, or `null` when the document is format 1. */
|
|
1098
|
+
getFormat2Store(docId: string | null | undefined): Format2RecordStore | null;
|
|
1099
|
+
/**
|
|
1100
|
+
* The epoch move a write to `docId` has to wait out (#2816, behavior 10), or
|
|
1101
|
+
* `null` when none is in flight.
|
|
1102
|
+
*/
|
|
1103
|
+
format2WritesHeld(docId: string | null | undefined): Promise<void> | null;
|
|
1104
|
+
/** Run an epoch move with `docId`'s local writes held back (#2816). */
|
|
1105
|
+
holdFormat2Writes<T>(docId: string, run: () => Promise<T>): Promise<T>;
|
|
1106
|
+
/** Notify `subscribe()` callbacks registered on this context for `modelName`. */
|
|
1107
|
+
notifyListeners(modelName: string, log?: (message: string) => void, onError?: (error: unknown) => void): void;
|
|
1108
|
+
setModelDefaultDocumentId(modelName: string, docId: string): void;
|
|
1109
|
+
removeModelDefaultDocumentId(modelName: string): void;
|
|
1110
|
+
clearModelDefaultDocumentIds(): void;
|
|
1111
|
+
setGlobalDefaultDocumentId(docId: string): void;
|
|
1112
|
+
clearGlobalDefaultDocumentId(): void;
|
|
1113
|
+
getModelDefaultDocumentMapping(): Record<string, string>;
|
|
1114
|
+
getDocumentIdForModel(modelName: string): string | undefined;
|
|
1115
|
+
getGlobalDefaultDocumentId(): string | undefined;
|
|
1116
|
+
onDefaultDocChanged(listener: (payload: {
|
|
1117
|
+
previous?: string;
|
|
1118
|
+
current?: string;
|
|
1119
|
+
}) => void): () => void;
|
|
1120
|
+
onModelDocMappingChanged(listener: (payload: {
|
|
1121
|
+
modelName: string;
|
|
1122
|
+
previous?: string;
|
|
1123
|
+
current?: string;
|
|
1124
|
+
}) => void): () => void;
|
|
1125
|
+
/** Drop the model→document defaults that point at a disconnected document. */
|
|
1126
|
+
clearMappingsForDocId(docId: string): void;
|
|
1127
|
+
private announceDefaultDoc;
|
|
1128
|
+
private announceModelDocMapping;
|
|
1129
|
+
/**
|
|
1130
|
+
* Tear this instance down: observers off, engine destroyed, state cleared.
|
|
1131
|
+
*
|
|
1132
|
+
* Deliberately does NOT unbind its model classes. They keep resolving this
|
|
1133
|
+
* context, whose engine is now null, so an operation through them throws the
|
|
1134
|
+
* ordinary not-initialized error instead of silently landing on whatever
|
|
1135
|
+
* other instance happens to be live (#3039, behavior 7). Only a later
|
|
1136
|
+
* `initJsBao` may rebind a class whose context is destroyed.
|
|
1137
|
+
*/
|
|
1138
|
+
destroy(): Promise<void>;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
401
1141
|
interface StringSetMembership {
|
|
402
1142
|
field: string;
|
|
403
1143
|
contains: string;
|
|
@@ -466,19 +1206,59 @@ declare class RecordNotFoundError extends Error {
|
|
|
466
1206
|
declare function generateULID(): string;
|
|
467
1207
|
declare class BaseModel implements StringSetChangeTracker {
|
|
468
1208
|
static modelName?: string;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
1209
|
+
/**
|
|
1210
|
+
* The ORM instance this class is bound to (#3039), set by `initJsBao`.
|
|
1211
|
+
*
|
|
1212
|
+
* Read through the static prototype chain by {@link resolveOrmContext}, so a
|
|
1213
|
+
* subclass inherits its parent's binding. Never reassigned by `destroy()`:
|
|
1214
|
+
* a destroyed instance's classes keep pointing at their tombstone rather
|
|
1215
|
+
* than falling back to a surviving instance.
|
|
1216
|
+
*/
|
|
1217
|
+
static _ormContext?: OrmContext;
|
|
1218
|
+
/**
|
|
1219
|
+
* Every field below is per-ORM-instance state (#3039). The accessors keep
|
|
1220
|
+
* the static surface consumers (and this file's own call sites) already use
|
|
1221
|
+
* while the state itself lives on the acting class's {@link OrmContext}.
|
|
1222
|
+
*/
|
|
1223
|
+
protected static get listenersMap(): Map<string, Set<() => void>>;
|
|
1224
|
+
protected static set listenersMap(value: Map<string, Set<() => void>>);
|
|
1225
|
+
protected static get modelNameToDefaultDocId(): Map<string, string>;
|
|
1226
|
+
protected static set modelNameToDefaultDocId(value: Map<string, string>);
|
|
1227
|
+
protected static get globalDefaultDocId(): string | undefined;
|
|
1228
|
+
protected static set globalDefaultDocId(value: string | undefined);
|
|
1229
|
+
protected static get defaultDocChangedListeners(): Set<(payload: {
|
|
1230
|
+
previous?: string;
|
|
1231
|
+
current?: string;
|
|
1232
|
+
}) => void>;
|
|
1233
|
+
protected static get modelDocMappingChangedListeners(): Set<(payload: {
|
|
1234
|
+
modelName: string;
|
|
1235
|
+
previous?: string;
|
|
1236
|
+
current?: string;
|
|
1237
|
+
}) => void>;
|
|
474
1238
|
private static readonly DEFAULT_LEGACY_DOC_ID;
|
|
475
|
-
protected static dbInstance: DatabaseEngine | null;
|
|
476
|
-
protected static
|
|
1239
|
+
protected static get dbInstance(): DatabaseEngine | null;
|
|
1240
|
+
protected static set dbInstance(value: DatabaseEngine | null);
|
|
1241
|
+
protected static get connectedDocuments(): Map<string, {
|
|
477
1242
|
yDoc: Y.Doc;
|
|
478
1243
|
permissionHint: DocumentPermissionHint;
|
|
479
1244
|
}>;
|
|
480
|
-
protected static
|
|
481
|
-
|
|
1245
|
+
protected static set connectedDocuments(value: Map<string, {
|
|
1246
|
+
yDoc: Y.Doc;
|
|
1247
|
+
permissionHint: DocumentPermissionHint;
|
|
1248
|
+
}>);
|
|
1249
|
+
protected static get documentYMaps(): Map<string, Y.Map<any>>;
|
|
1250
|
+
protected static set documentYMaps(value: Map<string, Y.Map<any>>);
|
|
1251
|
+
/**
|
|
1252
|
+
* Format-2 ("large document", #2816) documents by id, each with the record
|
|
1253
|
+
* store that holds its merged view. Presence in this map IS format-2-ness on
|
|
1254
|
+
* the client: a document without an entry takes the legacy path unchanged.
|
|
1255
|
+
*/
|
|
1256
|
+
private static get format2Documents();
|
|
1257
|
+
/**
|
|
1258
|
+
* Large documents whose epoch overlay is being replaced right now (#2816,
|
|
1259
|
+
* behavior 10), each with the promise that settles when the move is done.
|
|
1260
|
+
*/
|
|
1261
|
+
protected static get format2Transitions(): Map<string, Promise<void>>;
|
|
482
1262
|
private _localChanges;
|
|
483
1263
|
private _isDirty;
|
|
484
1264
|
private _isLoadingFromYjs;
|
|
@@ -495,6 +1275,53 @@ declare class BaseModel implements StringSetChangeTracker {
|
|
|
495
1275
|
type: string;
|
|
496
1276
|
static setLogLevel(level: LogLevel): void;
|
|
497
1277
|
static getLogLevel(): LogLevel;
|
|
1278
|
+
/**
|
|
1279
|
+
* Mark a connected document as format 2 and bind the record store that holds
|
|
1280
|
+
* its merged view. Called by `connectDocument(..., { documentFormat: 2 })`.
|
|
1281
|
+
*/
|
|
1282
|
+
static registerFormat2Document(docId: string, store: Format2RecordStore): void;
|
|
1283
|
+
static unregisterFormat2Document(docId: string): void;
|
|
1284
|
+
/**
|
|
1285
|
+
* Drop every format-2 binding. Called when the whole runtime is reset: the
|
|
1286
|
+
* stores run over the engine's SQL executor, so one left behind would answer
|
|
1287
|
+
* reads out of a destroyed database.
|
|
1288
|
+
*/
|
|
1289
|
+
static clearFormat2Documents(): void;
|
|
1290
|
+
/** The record store for `docId`, or `null` when the document is format 1. */
|
|
1291
|
+
static getFormat2Store(docId: string | null | undefined): Format2RecordStore | null;
|
|
1292
|
+
static isFormat2Document(docId: string | null | undefined): boolean;
|
|
1293
|
+
/**
|
|
1294
|
+
* Run an epoch move with `docId`'s local writes held back (#2816,
|
|
1295
|
+
* behavior 10).
|
|
1296
|
+
*
|
|
1297
|
+
* A move replaces the document's overlay: the sealed Y.Doc is destroyed once
|
|
1298
|
+
* the fresh one is bound. A write that resolved its target document before
|
|
1299
|
+
* the move and lands its overlay entry after would land it in the doomed
|
|
1300
|
+
* doc — and it could never be recovered, because `_pending_ops` records
|
|
1301
|
+
* which fields a write touched, not the values it wrote. A write that starts
|
|
1302
|
+
* during the move therefore waits here and resolves the document again on
|
|
1303
|
+
* the other side. (Concurrent moves are serialized for the same reason.)
|
|
1304
|
+
*/
|
|
1305
|
+
static holdFormat2Writes<T>(docId: string, run: () => Promise<T>): Promise<T>;
|
|
1306
|
+
/**
|
|
1307
|
+
* The move a write to `docId` has to wait out, or `null` when none is in
|
|
1308
|
+
* flight — which is every write on every document but the one being rotated.
|
|
1309
|
+
*/
|
|
1310
|
+
static format2WritesHeld(docId: string | null | undefined): Promise<void> | null;
|
|
1311
|
+
/**
|
|
1312
|
+
* The record store, Y.Doc and model map a format-2 write should land on,
|
|
1313
|
+
* having waited out any epoch move in flight (#2816, behavior 10).
|
|
1314
|
+
*
|
|
1315
|
+
* Resolves to the callers' own values in the overwhelmingly common case that
|
|
1316
|
+
* no move is running, so an ordinary write pays one map lookup. When a move
|
|
1317
|
+
* IS running, the write waits for it and the freshly bound overlay — and its
|
|
1318
|
+
* rebuilt store — are what it lands on.
|
|
1319
|
+
*/
|
|
1320
|
+
static resolveFormat2Target(docId: string, modelName: string, store: Format2RecordStore, yDoc: Y.Doc, modelYMap: Y.Map<any>): Promise<{
|
|
1321
|
+
store: Format2RecordStore;
|
|
1322
|
+
yDoc: Y.Doc;
|
|
1323
|
+
modelYMap: Y.Map<any>;
|
|
1324
|
+
}>;
|
|
498
1325
|
static setModelDefaultDocumentId(modelName: string, docId: string): void;
|
|
499
1326
|
static removeModelDefaultDocumentId(modelName: string): void;
|
|
500
1327
|
static clearModelDefaultDocumentIds(): void;
|
|
@@ -544,10 +1371,24 @@ declare class BaseModel implements StringSetChangeTracker {
|
|
|
544
1371
|
hasFieldChanged(fieldKey: string): boolean;
|
|
545
1372
|
markStringSetChange(fieldName: string, operation: "add" | "remove" | "clear", value?: string): void;
|
|
546
1373
|
private getStringSetCurrentValues;
|
|
1374
|
+
/** The schema default for a field, or `undefined` when it has none. */
|
|
1375
|
+
private schemaDefaultFor;
|
|
547
1376
|
private getStringSetFromYjs;
|
|
548
1377
|
private getOrCreateStringSet;
|
|
549
1378
|
static initialize(_yDoc: Y.Doc, _db: DatabaseEngine): Promise<void>;
|
|
550
|
-
|
|
1379
|
+
/**
|
|
1380
|
+
* Initialize this model for a connected document.
|
|
1381
|
+
*
|
|
1382
|
+
* `documentFormat` (#2816) decides where the records come from. On a legacy
|
|
1383
|
+
* document the Y.Map IS the records, so this loads them into the query
|
|
1384
|
+
* tables and observes the map. On a format-2 document the Y.Map is an epoch
|
|
1385
|
+
* OVERLAY of flat `recordId/field` entries — walking it as records would
|
|
1386
|
+
* read a scalar (or a `null` unset) as a record and mint junk rows, and
|
|
1387
|
+
* observing it would run every overlay change through the legacy
|
|
1388
|
+
* record-shaped path. There the tables are prepared here and hydrated and
|
|
1389
|
+
* observed by `Format2DocumentBinding` from the merged view instead.
|
|
1390
|
+
*/
|
|
1391
|
+
static initializeForDocument(yDoc: Y.Doc, db: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint, documentFormat?: 1 | 2): Promise<void>;
|
|
551
1392
|
static cleanupDocumentData(docId: string): Promise<void>;
|
|
552
1393
|
static subscribe(callback: () => void): () => void;
|
|
553
1394
|
protected static notifyListeners(): void;
|
|
@@ -582,9 +1423,88 @@ declare class BaseModel implements StringSetChangeTracker {
|
|
|
582
1423
|
protected static _buildKeyFromValues(fields: string[], keyValues: any[], modelName: string, constraintName: string): string | null;
|
|
583
1424
|
protected _buildUniqueKey(fields: string[], data: Record<string, any>, modelName: string, constraintName: string): string | null;
|
|
584
1425
|
save(options?: SaveOptions): Promise<void>;
|
|
1426
|
+
/**
|
|
1427
|
+
* Save onto a format-2 (large) document (#2816).
|
|
1428
|
+
*
|
|
1429
|
+
* The record is NOT written as a nested `Y.Map`: the epoch doc carries flat
|
|
1430
|
+
* field-level overlay entries, and the merged `records` view is what reads
|
|
1431
|
+
* come from. Existence, `upsertOn` and uniqueness are therefore resolved
|
|
1432
|
+
* against that view, not against the doc, which is what makes a base-only
|
|
1433
|
+
* record patchable instead of silently re-created.
|
|
1434
|
+
*
|
|
1435
|
+
* The merged row and the `_pending_ops` entry commit TOGETHER and BEFORE the
|
|
1436
|
+
* overlay lands in the Y.Doc: that ordering is the commit barrier a resolved
|
|
1437
|
+
* `save()` stands behind, and it keeps a crash in between recoverable (the
|
|
1438
|
+
* durable pending op replays the write) rather than divergent.
|
|
1439
|
+
*/
|
|
1440
|
+
private saveFormat2;
|
|
1441
|
+
/**
|
|
1442
|
+
* Mirror one merged-view row into the engine's per-model query tables, so
|
|
1443
|
+
* `query()` / `aggregate()` answer from the same state `find()` does. The
|
|
1444
|
+
* merged view remains the source; this is its derived query projection, and
|
|
1445
|
+
* it is awaited so a resolved save is immediately queryable.
|
|
1446
|
+
*/
|
|
1447
|
+
/**
|
|
1448
|
+
* Project one merged-view row into the engine's per-model query tables, so
|
|
1449
|
+
* `query()` / `aggregate()` answer from the same state `find()` does. The
|
|
1450
|
+
* merged view stays the source; this is its derived query projection.
|
|
1451
|
+
*
|
|
1452
|
+
* Called (awaited) by the local write path — which is what makes a resolved
|
|
1453
|
+
* save immediately queryable — and enqueued by the overlay observer for
|
|
1454
|
+
* updates that arrive from the sync layer.
|
|
1455
|
+
*/
|
|
1456
|
+
static projectFormat2Record(docId: string, recordId: string,
|
|
1457
|
+
/**
|
|
1458
|
+
* `notify: false` withholds the listener notification, for a caller
|
|
1459
|
+
* projecting many records at once (#2816): hydrating a model would
|
|
1460
|
+
* otherwise tell every subscriber once per record, which on a large
|
|
1461
|
+
* document is a re-render per row of the document.
|
|
1462
|
+
*/
|
|
1463
|
+
options?: {
|
|
1464
|
+
notify?: boolean;
|
|
1465
|
+
}): Promise<void>;
|
|
1466
|
+
/**
|
|
1467
|
+
* Remove a PAGE of records from the query tables, without consulting the
|
|
1468
|
+
* merged view (#2816).
|
|
1469
|
+
*
|
|
1470
|
+
* The discard path: the rows are erased BEFORE the merged view is thrown
|
|
1471
|
+
* away, because the view is the only place that still names them. Projecting
|
|
1472
|
+
* would re-insert them — the record is still there at that point.
|
|
1473
|
+
*/
|
|
1474
|
+
static deleteFormat2QueryRows(recordIds: readonly string[]): Promise<void>;
|
|
1475
|
+
/**
|
|
1476
|
+
* Project a PAGE of merged-view rows into the query tables (#2816).
|
|
1477
|
+
*
|
|
1478
|
+
* The hydration path: one notification for the page rather than one per
|
|
1479
|
+
* record, because a model being hydrated has no intermediate states worth
|
|
1480
|
+
* telling anyone about — and on a large document the per-record version is a
|
|
1481
|
+
* subscriber callback per row of the document.
|
|
1482
|
+
*/
|
|
1483
|
+
static projectFormat2Records(docId: string, recordIds: readonly string[]): Promise<void>;
|
|
585
1484
|
delete(): Promise<void>;
|
|
1485
|
+
/**
|
|
1486
|
+
* Delete from a format-2 (large) document (#2816): a `_deleted` tombstone in
|
|
1487
|
+
* the overlay plus the merged-view removal, committed with its pending op
|
|
1488
|
+
* before the overlay lands, exactly as `saveFormat2` orders its write.
|
|
1489
|
+
*/
|
|
1490
|
+
private deleteFormat2;
|
|
586
1491
|
protected getCurrentJSState(): Record<string, any>;
|
|
587
1492
|
protected toJSON(): Record<string, any>;
|
|
1493
|
+
/**
|
|
1494
|
+
* Refuse a read through a class whose ORM instance has been destroyed
|
|
1495
|
+
* (#3039, the tombstone).
|
|
1496
|
+
*
|
|
1497
|
+
* `query()`, `count()` and `aggregate()` already refuse: they read the
|
|
1498
|
+
* engine, which a destroyed instance has nulled. The readers that walk the
|
|
1499
|
+
* connected documents instead — `find`, `findAll`, `findByUnique` — would
|
|
1500
|
+
* walk maps `destroy()` emptied and report absence, which a caller cannot
|
|
1501
|
+
* tell apart from a database that genuinely holds no such record. So they
|
|
1502
|
+
* say so instead.
|
|
1503
|
+
*
|
|
1504
|
+
* Only a DESTROYED context is refused. A class that was never bound still
|
|
1505
|
+
* reads as empty before any `initJsBao`, exactly as it always has.
|
|
1506
|
+
*/
|
|
1507
|
+
private static assertInstanceAlive;
|
|
588
1508
|
static find<T extends BaseModel>(this: new (...args: any[]) => T, id: string): Promise<T | null>;
|
|
589
1509
|
/**
|
|
590
1510
|
* Document-style query API - returns paginated results
|
|
@@ -703,14 +1623,14 @@ declare class BaseModel implements StringSetChangeTracker {
|
|
|
703
1623
|
* member arrives via Y.applyUpdate, or a local per-member set/delete).
|
|
704
1624
|
* Idempotent — re-calling on the same Y.Map is a no-op.
|
|
705
1625
|
*/
|
|
706
|
-
protected static observeStringSetMapOnce(nestedMap: Y.Map<any
|
|
1626
|
+
protected static observeStringSetMapOnce(nestedMap: Y.Map<any>, docId?: string | null): void;
|
|
707
1627
|
/**
|
|
708
1628
|
* Walk the schema's stringset fields on `recordYMap` and observe any nested
|
|
709
1629
|
* Y.Map values. Called from observer setup and from the parent observer body
|
|
710
1630
|
* so newly-arrived stringset Y.Maps (local migration or remote create) get
|
|
711
1631
|
* an observer too.
|
|
712
1632
|
*/
|
|
713
|
-
protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any): void;
|
|
1633
|
+
protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any, docId?: string | null): void;
|
|
714
1634
|
/**
|
|
715
1635
|
* Sets up deep observation on a nested YMap to sync field-level changes to the database
|
|
716
1636
|
*/
|
|
@@ -721,39 +1641,9 @@ declare class BaseModel implements StringSetChangeTracker {
|
|
|
721
1641
|
protected static setupNestedYMapObserverForDocument(recordId: string, recordYMap: Y.Map<any>, docId: string, permissionHint: DocumentPermissionHint): void;
|
|
722
1642
|
}
|
|
723
1643
|
|
|
724
|
-
interface
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
fields: Map<string, FieldOptions>;
|
|
728
|
-
}
|
|
729
|
-
declare class ModelRegistry {
|
|
730
|
-
private static instance;
|
|
731
|
-
private models;
|
|
732
|
-
private modelOptions;
|
|
733
|
-
private activeSessionModels;
|
|
734
|
-
private dbEngine;
|
|
735
|
-
private constructor();
|
|
736
|
-
static getInstance(): ModelRegistry;
|
|
737
|
-
registerModel(modelClass: any, options: ModelOptions, fields: Map<string, FieldOptions>): void;
|
|
738
|
-
getModelClass(name: string): typeof BaseModel | undefined;
|
|
739
|
-
getModelOptions(name: string): ModelOptions | undefined;
|
|
740
|
-
getModelInfo(modelName: string): RegisteredModelInfo | undefined;
|
|
741
|
-
getAllRegisteredModelsInfo(): RegisteredModelInfo[];
|
|
742
|
-
private getModelNameFromClass;
|
|
743
|
-
setExplicitModelsForSession(modelClasses?: (typeof BaseModel)[]): void;
|
|
744
|
-
initializeAll(yDoc: Y.Doc, dbEngine: DatabaseEngine): Promise<void>;
|
|
745
|
-
initializeAllForDocument(yDoc: Y.Doc, dbEngine: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint): Promise<void>;
|
|
746
|
-
removeDocumentData(docId: string, dbEngine: DatabaseEngine): Promise<void>;
|
|
747
|
-
initializeRelationships(): Promise<void>;
|
|
748
|
-
private addPrototypeMethod;
|
|
749
|
-
clearSessionState(): void;
|
|
750
|
-
getActiveModels(): Map<string, typeof BaseModel>;
|
|
751
|
-
private validateSessionModels;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
interface SQLJSEngineOptions {
|
|
755
|
-
wasmURL?: string;
|
|
756
|
-
locateFile?: (file: string) => string;
|
|
1644
|
+
interface SQLJSEngineOptions {
|
|
1645
|
+
wasmURL?: string;
|
|
1646
|
+
locateFile?: (file: string) => string;
|
|
757
1647
|
}
|
|
758
1648
|
declare class SqljsEngine implements DatabaseEngine {
|
|
759
1649
|
private static SQL;
|
|
@@ -777,6 +1667,21 @@ declare class SqljsEngine implements DatabaseEngine {
|
|
|
777
1667
|
deleteByDocumentId(modelName: string, docId: string): Promise<void>;
|
|
778
1668
|
query<T extends Record<string, any>>(sql: string, params?: any[]): Promise<T[]>;
|
|
779
1669
|
withTransaction<T>(callback: (transactionalOps: ITransactionalDatabaseOperations) => Promise<T>): Promise<T>;
|
|
1670
|
+
/**
|
|
1671
|
+
* Synchronous executor for the format-2 record store (#2816). SQL.js has no
|
|
1672
|
+
* savepoints exposed here, so nesting reuses the open transaction rather
|
|
1673
|
+
* than issuing a second BEGIN.
|
|
1674
|
+
*/
|
|
1675
|
+
/**
|
|
1676
|
+
* SQL.js is an in-memory database (#2816): `new SQL.Database()` and nothing
|
|
1677
|
+
* behind it. It can host a large document's merged view for the life of a
|
|
1678
|
+
* page, and cannot BE one — a reload would take the document and every write
|
|
1679
|
+
* the server has not acknowledged with it. Until the worker-hosted OPFS
|
|
1680
|
+
* engine lands, saying so is what turns that into a typed refusal before a
|
|
1681
|
+
* cold load starts writing, instead of a document that quietly evaporates.
|
|
1682
|
+
*/
|
|
1683
|
+
format2StorageIsDurable(): boolean;
|
|
1684
|
+
createFormat2Sql(): Format2StoreSql;
|
|
780
1685
|
close(): Promise<void>;
|
|
781
1686
|
getTableSchema(_tableName: string): Promise<any>;
|
|
782
1687
|
destroy(): Promise<void>;
|
|
@@ -831,20 +1736,536 @@ declare class BrowserDatabaseFactory {
|
|
|
831
1736
|
}[];
|
|
832
1737
|
}
|
|
833
1738
|
|
|
1739
|
+
/**
|
|
1740
|
+
* The on-the-wire shape of a snapshot chunk (#2816, Phase 4).
|
|
1741
|
+
*
|
|
1742
|
+
* Shared by BOTH ends, like the overlay fold: the Durable Object writes these
|
|
1743
|
+
* bytes and the client materializes them, so one implementation encodes,
|
|
1744
|
+
* verifies and decodes them. A snapshot that meant something slightly
|
|
1745
|
+
* different on the two ends would put records into a client's authoritative
|
|
1746
|
+
* table that the server never wrote — silently, because on a format-2 document
|
|
1747
|
+
* there is no whole-document comparison left to catch it.
|
|
1748
|
+
*
|
|
1749
|
+
* ## Why chunks, and why they are verified
|
|
1750
|
+
*
|
|
1751
|
+
* A 2GB document cannot be one object: the load has to survive a dropped
|
|
1752
|
+
* connection, report progress, and let a model answer queries before the rest
|
|
1753
|
+
* has arrived. So a snapshot is a set of per-model gzip ndjson chunks plus a
|
|
1754
|
+
* manifest naming each one's row count and digest, and a chunk is checked
|
|
1755
|
+
* against its entry BEFORE it is decoded — a mismatch is a typed refusal the
|
|
1756
|
+
* loader retries on, cheaper than inflating bytes that are already wrong.
|
|
1757
|
+
*
|
|
1758
|
+
* ## The line format
|
|
1759
|
+
*
|
|
1760
|
+
* One `⟨id⟩\t⟨_data JSON verbatim⟩` line per record. Verbatim matters: the
|
|
1761
|
+
* data comes out of SQLite's `json_patch` and goes straight back into the
|
|
1762
|
+
* other end's `records` table, so re-serializing it here would be a chance to
|
|
1763
|
+
* round a number the server stored exactly. Compact JSON has no raw newline or
|
|
1764
|
+
* tab in it, and a row that somehow held one is refused rather than written —
|
|
1765
|
+
* it would come back as two records, or as one with its tail lost.
|
|
1766
|
+
*/
|
|
1767
|
+
/** One row of the authoritative `records` table. */
|
|
1768
|
+
interface SnapshotRow {
|
|
1769
|
+
_type: string;
|
|
1770
|
+
_id: string;
|
|
1771
|
+
_data: string;
|
|
1772
|
+
}
|
|
1773
|
+
/** What the manifest says about one chunk: enough to verify it standalone. */
|
|
1774
|
+
interface SnapshotChunkEntry {
|
|
1775
|
+
/** R2 key, inside the document's own `{appId}/{docId}/` prefix. */
|
|
1776
|
+
key: string;
|
|
1777
|
+
/** The model whose records this chunk holds. */
|
|
1778
|
+
model: string;
|
|
1779
|
+
/** Stored (compressed) length, so a Range loader knows what it is owed. */
|
|
1780
|
+
bytes: number;
|
|
1781
|
+
/**
|
|
1782
|
+
* Uncompressed ndjson length.
|
|
1783
|
+
*
|
|
1784
|
+
* What the rows actually cost once they are somewhere other than a gzip
|
|
1785
|
+
* stream — which is the number a device deciding whether it can hold this
|
|
1786
|
+
* document needs. Compressed length is not a proxy for it: highly
|
|
1787
|
+
* compressible JSON is exactly the shape these chunks hold. Optional
|
|
1788
|
+
* because a manifest written before it was recorded carries none.
|
|
1789
|
+
*/
|
|
1790
|
+
rawBytes?: number;
|
|
1791
|
+
rows: number;
|
|
1792
|
+
firstId: string;
|
|
1793
|
+
lastId: string;
|
|
1794
|
+
/** Hex SHA-256 of the STORED bytes — checkable before decompressing. */
|
|
1795
|
+
sha256: string;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* What one build wrote: the chunks, and everything a loader needs before the
|
|
1799
|
+
* first of them lands.
|
|
1800
|
+
*
|
|
1801
|
+
* `epoch` is the epoch this snapshot is a BASE for — the state as of that
|
|
1802
|
+
* epoch's start, give or take the torn read the overlays after it repair. The
|
|
1803
|
+
* schema (D5) travels with it so a client can create a model's tables without
|
|
1804
|
+
* a document to discover them from.
|
|
1805
|
+
*/
|
|
1806
|
+
interface SnapshotManifest {
|
|
1807
|
+
version: 2;
|
|
1808
|
+
epoch: number;
|
|
1809
|
+
buildId: string;
|
|
1810
|
+
createdAt: number;
|
|
1811
|
+
schema: Record<string, unknown>;
|
|
1812
|
+
chunks: SnapshotChunkEntry[];
|
|
1813
|
+
totals: {
|
|
1814
|
+
rows: number;
|
|
1815
|
+
bytes: number;
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Read a chunk back, verified against its manifest entry when one is offered.
|
|
1820
|
+
*
|
|
1821
|
+
* The builder round-trips its own bytes and has nothing to verify against; the
|
|
1822
|
+
* loader always passes the entry, which is what makes a truncated or swapped
|
|
1823
|
+
* chunk a typed refusal rather than records the server never wrote.
|
|
1824
|
+
*/
|
|
1825
|
+
declare function readSnapshotChunk(body: Uint8Array, descriptor: SnapshotChunkEntry | {
|
|
1826
|
+
model: string;
|
|
1827
|
+
}): Promise<SnapshotRow[]>;
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* The cold load of a large document (#2816, behavior 15).
|
|
1831
|
+
*
|
|
1832
|
+
* A new or evicted client does not replay a document's history — on a format-2
|
|
1833
|
+
* document there is none to replay — it streams the chunks of the latest base
|
|
1834
|
+
* snapshot into its own `records` table and then applies the overlays sealed
|
|
1835
|
+
* after it. This module is the first half: manifest in, merged view out.
|
|
1836
|
+
*
|
|
1837
|
+
* What makes it usable on a real device rather than only in principle:
|
|
1838
|
+
*
|
|
1839
|
+
* - **Resumable.** Each chunk's rows and its `_snapshot_load` mark are written
|
|
1840
|
+
* in ONE commit, so an interrupted load has no half-applied chunk and a
|
|
1841
|
+
* resume downloads only what is missing. On a multi-hundred-MB document that
|
|
1842
|
+
* is the difference between a load that eventually finishes and one that
|
|
1843
|
+
* starts over every time the connection drops.
|
|
1844
|
+
* - **Verified.** A chunk goes straight into the authoritative table, so a
|
|
1845
|
+
* truncated or swapped one would write records the server never had, with no
|
|
1846
|
+
* whole-document comparison left to notice. The digest is checked before
|
|
1847
|
+
* anything is applied and a bad chunk is re-fetched; a chunk that stays bad
|
|
1848
|
+
* ends the load rather than completing a wrong one.
|
|
1849
|
+
* - **Progressive.** A model is answerable as soon as ITS chunks are in, and
|
|
1850
|
+
* progress is reported per chunk — a load that reports only at the end is
|
|
1851
|
+
* indistinguishable from a hang.
|
|
1852
|
+
* - **Bounded by the device.** A load may be capped to the models a platform
|
|
1853
|
+
* that cannot hold the whole document has room for (Precondition B's
|
|
1854
|
+
* fallback, see `format2StorageCapability`); a capped-out model's chunks are
|
|
1855
|
+
* never fetched, so the cap is bandwidth saved as well as space.
|
|
1856
|
+
*
|
|
1857
|
+
* Materialization goes through `applyRemote`, the same fold an overlay entry
|
|
1858
|
+
* takes, so a snapshot cannot come to mean something an overlay does not.
|
|
1859
|
+
*/
|
|
1860
|
+
|
|
1861
|
+
/** A chunk that did not become the bytes the manifest describes, twice. */
|
|
1862
|
+
declare class SnapshotLoadIntegrityError extends Error {
|
|
1863
|
+
readonly chunk: SnapshotChunkEntry;
|
|
1864
|
+
readonly cause: unknown;
|
|
1865
|
+
constructor(chunk: SnapshotChunkEntry, cause: unknown);
|
|
1866
|
+
}
|
|
1867
|
+
/** How far a load has got. */
|
|
1868
|
+
interface SnapshotLoadProgress {
|
|
1869
|
+
/** Rows materialized so far, including a resumed run's earlier ones. */
|
|
1870
|
+
rows: number;
|
|
1871
|
+
totalRows: number;
|
|
1872
|
+
chunks: number;
|
|
1873
|
+
totalChunks: number;
|
|
1874
|
+
/** The model the chunk just applied belongs to. */
|
|
1875
|
+
model: string;
|
|
1876
|
+
}
|
|
1877
|
+
/** What a finished load did. */
|
|
1878
|
+
interface SnapshotLoadResult {
|
|
1879
|
+
rows: number;
|
|
1880
|
+
chunks: number;
|
|
1881
|
+
/** Chunks a previous attempt had already committed. */
|
|
1882
|
+
resumed: number;
|
|
1883
|
+
/** Chunks that had to be fetched twice because the first copy was wrong. */
|
|
1884
|
+
refetched: number;
|
|
1885
|
+
models: string[];
|
|
1886
|
+
/**
|
|
1887
|
+
* Models the snapshot carries that this load left behind, because the
|
|
1888
|
+
* device could not hold them (#2816, Precondition B's fallback).
|
|
1889
|
+
*/
|
|
1890
|
+
skipped: string[];
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Where one chunk is read from, WITHIN the grant for its build.
|
|
1894
|
+
*
|
|
1895
|
+
* `{model}/{index}` — not a key and not a path: the client never learns an R2
|
|
1896
|
+
* key, and the download route derives the object from the grant plus exactly
|
|
1897
|
+
* these two values.
|
|
1898
|
+
*/
|
|
1899
|
+
declare function snapshotChunkPath(chunk: SnapshotChunkEntry): string;
|
|
1900
|
+
/**
|
|
1901
|
+
* Load a snapshot's chunks into the client's merged view.
|
|
1902
|
+
*
|
|
1903
|
+
* Chunks are applied in manifest order, which is `(model, id)` order — so a
|
|
1904
|
+
* model's chunks are contiguous and it can be reported ready as soon as its
|
|
1905
|
+
* last one lands.
|
|
1906
|
+
*/
|
|
1907
|
+
declare function loadFormat2Snapshot(options: {
|
|
1908
|
+
store: Format2RecordStore;
|
|
1909
|
+
manifest: SnapshotManifest;
|
|
1910
|
+
/** Read one chunk's bytes; the caller owns the transport and the grant. */
|
|
1911
|
+
fetchChunk(chunk: SnapshotChunkEntry): Promise<Uint8Array>;
|
|
1912
|
+
onProgress?(progress: SnapshotLoadProgress): void;
|
|
1913
|
+
/**
|
|
1914
|
+
* A model whose last chunk has landed. Awaited: "ready" has to mean
|
|
1915
|
+
* QUERYABLE, and the caller may have derived tables to hydrate before it
|
|
1916
|
+
* can honestly say so.
|
|
1917
|
+
*/
|
|
1918
|
+
onModelReady?(model: string): void | Promise<void>;
|
|
1919
|
+
/** Re-fetches allowed for a chunk that arrives corrupt. Default 1. */
|
|
1920
|
+
retries?: number;
|
|
1921
|
+
/**
|
|
1922
|
+
* The models to hydrate, from {@link planSnapshotHydration} — omitted to
|
|
1923
|
+
* take the whole snapshot.
|
|
1924
|
+
*
|
|
1925
|
+
* A model outside the cap is not fetched at all, so a device that cannot
|
|
1926
|
+
* hold the document does not spend its bandwidth on the part it will drop.
|
|
1927
|
+
*/
|
|
1928
|
+
models?: readonly string[];
|
|
1929
|
+
}): Promise<SnapshotLoadResult>;
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* What the transport needs to know about a format-2 document's local writes
|
|
1933
|
+
* (#2816, behavior 9).
|
|
1934
|
+
*
|
|
1935
|
+
* The client transport (`JsBaoClient`) owns the socket but not the record
|
|
1936
|
+
* store, and the ORM owns the store but not the socket. These two functions
|
|
1937
|
+
* are the whole boundary between them: the transport stamps an outbound update
|
|
1938
|
+
* frame with the sequences below, and hands back the server's
|
|
1939
|
+
* `update.ack {maxContiguousSeq}` — the only thing that may prune a durable
|
|
1940
|
+
* `_pending_ops` row.
|
|
1941
|
+
*
|
|
1942
|
+
* A document that is not format 2 has no marks, and both functions say so
|
|
1943
|
+
* rather than inventing zeroes, so the legacy path stays exactly as it was.
|
|
1944
|
+
*/
|
|
1945
|
+
|
|
1946
|
+
interface Format2SyncMarks {
|
|
1947
|
+
/** Highest sequence this client has committed locally. */
|
|
1948
|
+
highestLocalSeq: number;
|
|
1949
|
+
/** Highest sequence the server has confirmed durable. */
|
|
1950
|
+
ackedSeq: number;
|
|
1951
|
+
/** The epoch this document's overlay belongs to; `0` before it learns one. */
|
|
1952
|
+
epoch: number;
|
|
1953
|
+
/**
|
|
1954
|
+
* Whether this client ever measured its clock against the server's (#2816,
|
|
1955
|
+
* behavior 17). Without it, a replay refuses to call an offline write
|
|
1956
|
+
* "clearly older" than an online one.
|
|
1957
|
+
*/
|
|
1958
|
+
clockOffsetKnown: boolean;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Whether this platform can hold this document (#2816, Precondition B's
|
|
1963
|
+
* fallback).
|
|
1964
|
+
*
|
|
1965
|
+
* A cold load streams a base snapshot straight into the local record store,
|
|
1966
|
+
* chunk by chunk, and on the documents this epic exists for that is minutes of
|
|
1967
|
+
* writing. Precondition B is the case where the store underneath cannot take
|
|
1968
|
+
* it: a browser engine with no persistent VFS at all, or one whose origin
|
|
1969
|
+
* quota is well under the document — Safari/iPadOS OPFS, whose measurement is
|
|
1970
|
+
* what gates enabling the OPFS engine there.
|
|
1971
|
+
*
|
|
1972
|
+
* The design ships this check regardless of how that measurement lands,
|
|
1973
|
+
* because the failure it prevents is the quiet one. Without it a load starts,
|
|
1974
|
+
* writes for ten minutes, and throws on the chunk that crosses the quota,
|
|
1975
|
+
* leaving a `records` table holding some of the document — which the client
|
|
1976
|
+
* will nonetheless answer queries from, since the merged view IS the document
|
|
1977
|
+
* on format 2. A partial merged view is not a slower document, it is a wrong
|
|
1978
|
+
* one.
|
|
1979
|
+
*
|
|
1980
|
+
* So the decision is taken before the first chunk is fetched, from what the
|
|
1981
|
+
* platform reports:
|
|
1982
|
+
*
|
|
1983
|
+
* - **no persistent store** → typed refusal. A view that evaporates when the
|
|
1984
|
+
* tab closes cannot be the authoritative copy of anything.
|
|
1985
|
+
* - **quota the whole snapshot does not fit in** → hydrate the models the app
|
|
1986
|
+
* configured as the ones worth keeping, and say which were dropped. A device
|
|
1987
|
+
* that can hold the hot models is more useful than one that holds nothing.
|
|
1988
|
+
* - **quota not even those fit in** → typed refusal, never a partial load
|
|
1989
|
+
* reported as a whole one.
|
|
1990
|
+
* - **no quota reported** → hydrate everything. Refusing on ignorance would
|
|
1991
|
+
* take format 2 away from every engine without a quota API, and the platforms
|
|
1992
|
+
* that do not answer are the ones with room.
|
|
1993
|
+
*
|
|
1994
|
+
* The plan it returns is what {@link loadFormat2Snapshot} is given: the cap is
|
|
1995
|
+
* enforced by not fetching the skipped models' chunks at all, so it is bytes
|
|
1996
|
+
* not transferred, not just bytes not stored.
|
|
1997
|
+
*/
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* How much local space a chunk's UNCOMPRESSED ndjson takes once materialized.
|
|
2001
|
+
*
|
|
2002
|
+
* The rows land as SQLite rows plus their indexes, so the number is above one.
|
|
2003
|
+
* Two is deliberately pessimistic: the cost of over-estimating is hydrating
|
|
2004
|
+
* fewer models than a device could have held, and the cost of
|
|
2005
|
+
* under-estimating is the mid-load failure this module exists to prevent.
|
|
2006
|
+
*/
|
|
2007
|
+
declare const SNAPSHOT_STORAGE_EXPANSION = 2;
|
|
2008
|
+
/**
|
|
2009
|
+
* What a chunk's STORED bytes are assumed to inflate to when its manifest does
|
|
2010
|
+
* not say.
|
|
2011
|
+
*
|
|
2012
|
+
* Only a manifest written before the uncompressed length was recorded needs
|
|
2013
|
+
* this. It is high because the guess must not be optimistic: gzipped ndjson of
|
|
2014
|
+
* repetitive JSON records routinely compresses by an order of magnitude, and
|
|
2015
|
+
* treating the compressed size as a proxy for the materialized one is exactly
|
|
2016
|
+
* how a device approves a document it cannot hold.
|
|
2017
|
+
*/
|
|
2018
|
+
declare const SNAPSHOT_COMPRESSED_EXPANSION = 8;
|
|
2019
|
+
/**
|
|
2020
|
+
* What one chunk is expected to cost in the local store once materialized.
|
|
2021
|
+
*
|
|
2022
|
+
* From the uncompressed length the manifest carries, because the compressed
|
|
2023
|
+
* length says more about how repetitive the data is than about how much room
|
|
2024
|
+
* it needs.
|
|
2025
|
+
*/
|
|
2026
|
+
declare function estimateMaterializedBytes(chunk: SnapshotChunkEntry): number;
|
|
2027
|
+
/** What the platform says about the store a merged view would live in. */
|
|
2028
|
+
interface StorageCapability {
|
|
2029
|
+
/** Whether what is written survives the tab or app going away. */
|
|
2030
|
+
persistent: boolean;
|
|
2031
|
+
/** Bytes this origin may keep, or `null` when the platform will not say. */
|
|
2032
|
+
quotaBytes: number | null;
|
|
2033
|
+
/** Bytes the origin already holds — other documents included. Default 0. */
|
|
2034
|
+
usedBytes?: number;
|
|
2035
|
+
/**
|
|
2036
|
+
* Whether a PLATFORM storage API answered, as opposed to there having been
|
|
2037
|
+
* none to ask.
|
|
2038
|
+
*
|
|
2039
|
+
* The difference matters when the engine underneath is in memory. A browser
|
|
2040
|
+
* that reports an origin-private file system is describing the platform, not
|
|
2041
|
+
* this session's database, so "persistent" from it plus a volatile engine is
|
|
2042
|
+
* a claim that is simply wrong — and the load is refused. A host with no
|
|
2043
|
+
* storage API at all (Node, a worker with its own store) was never claiming
|
|
2044
|
+
* anything: it chose the database it passed, so an in-memory one is its
|
|
2045
|
+
* decision to make, and it is warned rather than refused.
|
|
2046
|
+
*/
|
|
2047
|
+
probed?: boolean;
|
|
2048
|
+
}
|
|
2049
|
+
/** A device that cannot hold the document, said so before the load begins. */
|
|
2050
|
+
declare class Format2StorageCapabilityError extends Error {
|
|
2051
|
+
/** `not-persistent`: nothing survives. `over-quota`: not enough room. */
|
|
2052
|
+
readonly reason: "not-persistent" | "over-quota";
|
|
2053
|
+
/**
|
|
2054
|
+
* Materialized bytes the refused hydration would have needed, or `0` when
|
|
2055
|
+
* nothing has been measured — the bind refuses before there is a manifest
|
|
2056
|
+
* to measure, and on a document that may have no snapshot at all.
|
|
2057
|
+
*/
|
|
2058
|
+
readonly requiredBytes: number;
|
|
2059
|
+
/** Bytes the platform offered, or `null` when it reported no quota. */
|
|
2060
|
+
readonly availableBytes: number | null;
|
|
2061
|
+
/** The configured models this was measured against; empty when uncapped. */
|
|
2062
|
+
readonly models: string[];
|
|
2063
|
+
/** Stable across builds, so an app can branch on it rather than on text. */
|
|
2064
|
+
readonly code = "FORMAT2_STORAGE_UNAVAILABLE";
|
|
2065
|
+
constructor(
|
|
2066
|
+
/** `not-persistent`: nothing survives. `over-quota`: not enough room. */
|
|
2067
|
+
reason: "not-persistent" | "over-quota",
|
|
2068
|
+
/**
|
|
2069
|
+
* Materialized bytes the refused hydration would have needed, or `0` when
|
|
2070
|
+
* nothing has been measured — the bind refuses before there is a manifest
|
|
2071
|
+
* to measure, and on a document that may have no snapshot at all.
|
|
2072
|
+
*/
|
|
2073
|
+
requiredBytes: number,
|
|
2074
|
+
/** Bytes the platform offered, or `null` when it reported no quota. */
|
|
2075
|
+
availableBytes: number | null,
|
|
2076
|
+
/** The configured models this was measured against; empty when uncapped. */
|
|
2077
|
+
models: string[]);
|
|
2078
|
+
}
|
|
2079
|
+
/** Which models a cold load should hydrate on this device. */
|
|
2080
|
+
interface HydrationPlan {
|
|
2081
|
+
/** `full` took the whole snapshot; `capped` left models behind. */
|
|
2082
|
+
kind: "full" | "capped";
|
|
2083
|
+
/** Models to hydrate, in the manifest's order. */
|
|
2084
|
+
models: string[];
|
|
2085
|
+
/** Models the snapshot carries that this device is not taking. */
|
|
2086
|
+
skipped: string[];
|
|
2087
|
+
/** Materialized bytes the plan is expected to cost. */
|
|
2088
|
+
bytes: number;
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* What the platform underneath will let a merged view keep.
|
|
2092
|
+
*
|
|
2093
|
+
* `persistent` is "there is a durable store to put it in", not "the user
|
|
2094
|
+
* granted persistence": an origin whose data is evictable still holds the
|
|
2095
|
+
* document until it is evicted, and a client that has been evicted cold-loads
|
|
2096
|
+
* again. What it cannot survive is having nowhere to write at all — which on
|
|
2097
|
+
* the browser means no origin-private file system, the platform Precondition B
|
|
2098
|
+
* measures.
|
|
2099
|
+
*
|
|
2100
|
+
* A host that knows better (a worker with its own VFS, a test) passes a
|
|
2101
|
+
* capability of its own instead of calling this.
|
|
2102
|
+
*/
|
|
2103
|
+
declare function probeStorageCapability(nav?: unknown): Promise<StorageCapability>;
|
|
2104
|
+
/**
|
|
2105
|
+
* Decide what to hydrate before the load starts.
|
|
2106
|
+
*
|
|
2107
|
+
* Throws {@link Format2StorageCapabilityError} when the device cannot hold
|
|
2108
|
+
* even the configured models — the app is told, rather than the load being
|
|
2109
|
+
* started and abandoned partway through.
|
|
2110
|
+
*/
|
|
2111
|
+
declare function planSnapshotHydration(input: {
|
|
2112
|
+
manifest: SnapshotManifest;
|
|
2113
|
+
capability: StorageCapability;
|
|
2114
|
+
/**
|
|
2115
|
+
* The models to keep when the whole document will not fit — the app's
|
|
2116
|
+
* statement of what is worth the space it has. Ignored when everything fits:
|
|
2117
|
+
* this is a fallback for a device that cannot take the document, not a
|
|
2118
|
+
* permanent narrowing of it.
|
|
2119
|
+
*/
|
|
2120
|
+
models?: readonly string[];
|
|
2121
|
+
/**
|
|
2122
|
+
* Chunk keys a previous attempt already committed (`_snapshot_load`). Their
|
|
2123
|
+
* rows are already on the device, so they are what it has to make room for
|
|
2124
|
+
* only once.
|
|
2125
|
+
*/
|
|
2126
|
+
completedChunks?: Iterable<string>;
|
|
2127
|
+
}): HydrationPlan;
|
|
2128
|
+
|
|
834
2129
|
interface InitJsBaoOptions {
|
|
835
2130
|
databaseConfig: DatabaseConfig;
|
|
836
2131
|
/**
|
|
837
2132
|
* Optional array of model classes to initialize.
|
|
838
2133
|
* If not provided, the ORM will rely on models being registered
|
|
839
2134
|
* via attachAndRegisterModel (assuming they have been imported by the application).
|
|
2135
|
+
*
|
|
2136
|
+
* Either way the instance takes a SNAPSHOT of the models at this moment: a
|
|
2137
|
+
* class registered globally afterwards belongs to no live instance until the
|
|
2138
|
+
* next `initJsBao` picks it up. That is the session semantics this call has
|
|
2139
|
+
* always had; with per-instance state (#3039) it is worth saying out loud,
|
|
2140
|
+
* because a late registration now has several instances it could mean.
|
|
840
2141
|
*/
|
|
841
2142
|
models?: (typeof BaseModel)[];
|
|
2143
|
+
/**
|
|
2144
|
+
* What this device can hold of a large document, and what to keep when it
|
|
2145
|
+
* cannot hold all of it (#2816, Precondition B's fallback).
|
|
2146
|
+
*
|
|
2147
|
+
* `capability` overrides the platform probe — a host that owns its own store
|
|
2148
|
+
* knows better than `navigator.storage` does. `models` is the app's
|
|
2149
|
+
* statement of which models are worth the space when the document does not
|
|
2150
|
+
* fit; with none configured, a device short of space is refused rather than
|
|
2151
|
+
* loaded arbitrarily.
|
|
2152
|
+
*/
|
|
2153
|
+
largeDocumentStorage?: {
|
|
2154
|
+
capability?: StorageCapability;
|
|
2155
|
+
models?: string[];
|
|
2156
|
+
};
|
|
842
2157
|
}
|
|
843
2158
|
interface InitJsBaoResult {
|
|
844
2159
|
dbEngine: DatabaseEngine;
|
|
845
2160
|
modelRegistry: ModelRegistry;
|
|
846
|
-
|
|
2161
|
+
/**
|
|
2162
|
+
* Which instance this is. The first-wins warning a shared model class
|
|
2163
|
+
* produces names the instance that keeps the class and the one that loses
|
|
2164
|
+
* it by this number, so a process holding several clients can tell them
|
|
2165
|
+
* apart.
|
|
2166
|
+
*/
|
|
2167
|
+
instanceId: number;
|
|
2168
|
+
/**
|
|
2169
|
+
* Tear THIS ORM instance down (#3039): its observers are removed, its engine
|
|
2170
|
+
* destroyed and its state cleared, without touching any other instance.
|
|
2171
|
+
*
|
|
2172
|
+
* Its model classes are deliberately not unbound — they keep resolving this
|
|
2173
|
+
* (now destroyed) instance, so an operation through them throws the ordinary
|
|
2174
|
+
* not-initialized error instead of silently landing on a surviving client's
|
|
2175
|
+
* database.
|
|
2176
|
+
*/
|
|
2177
|
+
destroy: () => Promise<void>;
|
|
2178
|
+
connectDocument: (docId: string, yDoc: Y.Doc, permissionHint: DocumentPermissionHint, options?: ConnectDocumentOptions) => Promise<void>;
|
|
847
2179
|
disconnectDocument: (docId: string) => Promise<void>;
|
|
2180
|
+
/**
|
|
2181
|
+
* Format-2 (#2816) durable-acknowledgement marks for `docId`, or `null`
|
|
2182
|
+
* when it is a legacy document. The transport stamps an outbound frame
|
|
2183
|
+
* with these and prunes through `format2Acknowledge`.
|
|
2184
|
+
*/
|
|
2185
|
+
format2SyncMarks: (docId: string) => Format2SyncMarks | null;
|
|
2186
|
+
/**
|
|
2187
|
+
* The epoch `docId`'s own RECORD DATABASE says it follows, readable before
|
|
2188
|
+
* the document is bound (#2816). `0` when this database follows none —
|
|
2189
|
+
* whatever the client's metadata claims — and `null` when the engine cannot
|
|
2190
|
+
* be asked.
|
|
2191
|
+
*/
|
|
2192
|
+
format2StoredEpoch: (docId: string) => number | null;
|
|
2193
|
+
format2Acknowledge: (docId: string, maxContiguousSeq: number) => void;
|
|
2194
|
+
/** Record the epoch a connection joined, for a document with no mark yet. */
|
|
2195
|
+
format2NoteEpoch: (docId: string, epoch: number) => void;
|
|
2196
|
+
/**
|
|
2197
|
+
* Record that `docId` was in touch with the server, and the offline window
|
|
2198
|
+
* the server reported (#2816, behavior 18). Past that window without a sync
|
|
2199
|
+
* the document refuses local writes and keeps serving reads.
|
|
2200
|
+
*/
|
|
2201
|
+
format2NoteSync: (docId: string, at: number, windowDays?: number) => void;
|
|
2202
|
+
/**
|
|
2203
|
+
* Record the offline window the handshake reported, without claiming a sync
|
|
2204
|
+
* (#2816, behavior 18) — a frame is proof of contact, not proof that this
|
|
2205
|
+
* client's merged view is the room's state.
|
|
2206
|
+
*/
|
|
2207
|
+
format2NoteWindow: (docId: string, windowDays?: number) => void;
|
|
2208
|
+
/**
|
|
2209
|
+
* Resolves once the query tables have caught up with the merged view
|
|
2210
|
+
* (#2816). `find()` reads the merged view, which a fold updates
|
|
2211
|
+
* synchronously; `query()` reads the derived tables, which the projection
|
|
2212
|
+
* queue updates after it — so an update from the room counts as applied only
|
|
2213
|
+
* once this settles.
|
|
2214
|
+
*/
|
|
2215
|
+
whenFormat2ProjectionSettled: () => Promise<void>;
|
|
2216
|
+
/** Record the clock offset measured against a server frame (#2816). */
|
|
2217
|
+
format2NoteClockOffset: (docId: string, offsetMs: number) => void;
|
|
2218
|
+
/** Forget the pending ops a recency resolution dropped (#2816). */
|
|
2219
|
+
format2ForgetPendingOps: (docId: string, seqs: number[]) => void;
|
|
2220
|
+
/**
|
|
2221
|
+
* Rewrite the pending ops a recency resolution narrowed (#2816): the durable
|
|
2222
|
+
* log records the surviving delta, so a crash before the replay is
|
|
2223
|
+
* acknowledged cannot put the dropped field back.
|
|
2224
|
+
*/
|
|
2225
|
+
format2NarrowPendingOps: (docId: string, ops: Array<Pick<PendingOp, "seq" | "fields" | "mutation">>) => void;
|
|
2226
|
+
/**
|
|
2227
|
+
* The unacknowledged local writes of `docId` — what a client carries onto
|
|
2228
|
+
* the next epoch's overlay when it follows a seal (#2816, behavior 10).
|
|
2229
|
+
*/
|
|
2230
|
+
format2PendingOps: (docId: string) => PendingOp[];
|
|
2231
|
+
/** Move the epoch mark after the new epoch's overlay has been installed. */
|
|
2232
|
+
format2SetEpoch: (docId: string, epoch: number) => void;
|
|
2233
|
+
/**
|
|
2234
|
+
* Run an epoch move with `docId`'s local writes held back: a write that
|
|
2235
|
+
* landed on the sealed overlay after what is owed had been read off it would
|
|
2236
|
+
* go down with that document (#2816, behavior 10).
|
|
2237
|
+
*/
|
|
2238
|
+
format2HoldWrites: <T>(docId: string, run: () => Promise<T>) => Promise<T>;
|
|
2239
|
+
/**
|
|
2240
|
+
* Re-point a connected large document at the next epoch's overlay, without
|
|
2241
|
+
* disconnecting it — a rotation must not drop model→document mappings.
|
|
2242
|
+
*/
|
|
2243
|
+
format2RebindEpochDocument: (docId: string, yDoc: Y.Doc, options?: {
|
|
2244
|
+
restorePending?: boolean;
|
|
2245
|
+
}) => Promise<void>;
|
|
2246
|
+
/**
|
|
2247
|
+
* Fold one sealed epoch's archived overlay into `docId`'s merged view — the
|
|
2248
|
+
* step a client behind by whole epochs repeats per archive to catch up
|
|
2249
|
+
* (#2816, behavior 12).
|
|
2250
|
+
*/
|
|
2251
|
+
format2ApplySealedOverlay: (docId: string, update: Uint8Array) => Promise<void>;
|
|
2252
|
+
/**
|
|
2253
|
+
* Throw away a large document's merged view before it is rebuilt from a
|
|
2254
|
+
* base — what a client does when the sealed-overlay chain that would have
|
|
2255
|
+
* caught it up cannot be trusted (#2816, behavior 12).
|
|
2256
|
+
*/
|
|
2257
|
+
format2DiscardMergedView: (docId: string) => Promise<void>;
|
|
2258
|
+
/**
|
|
2259
|
+
* Fold a large document's open-epoch overlay into its merged view again —
|
|
2260
|
+
* the step that finishes a cold load, since a base snapshot's rows replace
|
|
2261
|
+
* what the open epoch had already contributed (#2816, behavior 15).
|
|
2262
|
+
*/
|
|
2263
|
+
format2RefoldOverlay: (docId: string) => Promise<void>;
|
|
2264
|
+
/** Materialize a base snapshot into a large document's merged view (#2816). */
|
|
2265
|
+
format2LoadSnapshot: (docId: string, manifest: SnapshotManifest, fetchChunk: (chunk: SnapshotChunkEntry) => Promise<Uint8Array>, callbacks?: {
|
|
2266
|
+
onProgress?: (progress: SnapshotLoadProgress) => void;
|
|
2267
|
+
onModelReady?: (model: string) => void | Promise<void>;
|
|
2268
|
+
}) => Promise<SnapshotLoadResult>;
|
|
848
2269
|
getConnectedDocuments: () => Map<string, ConnectedDocument>;
|
|
849
2270
|
isDocumentConnected: (docId: string) => boolean;
|
|
850
2271
|
onDocumentConnectionChange: (callback: DocumentConnectionCallback) => () => void;
|
|
@@ -866,13 +2287,25 @@ interface InitJsBaoResult {
|
|
|
866
2287
|
current?: string;
|
|
867
2288
|
}) => void) => () => void;
|
|
868
2289
|
}
|
|
2290
|
+
|
|
869
2291
|
/**
|
|
870
|
-
* Initializes the js-bao system for browser environments with multi-document
|
|
871
|
-
* Sets up the database engine and prepares for document connections.
|
|
2292
|
+
* Initializes the js-bao system for browser environments with multi-document
|
|
2293
|
+
* support. Sets up the database engine and prepares for document connections.
|
|
2294
|
+
*
|
|
2295
|
+
* Shares its whole body with the Node entrypoint (`initialize-core.ts`) — the
|
|
2296
|
+
* two differ only in how the engine is obtained. They used to be near-duplicate
|
|
2297
|
+
* 600-line files, which is how the browser build came to reset less state than
|
|
2298
|
+
* the Node one did.
|
|
2299
|
+
*
|
|
2300
|
+
* Per-instance semantics are identical to {@link initJsBao} in `initialize.ts`:
|
|
2301
|
+
* one instance per call, first-wins model binding, `destroy()` on the result.
|
|
872
2302
|
*/
|
|
873
2303
|
declare function initJsBao(options: InitJsBaoOptions): Promise<InitJsBaoResult>;
|
|
874
2304
|
/**
|
|
875
|
-
*
|
|
2305
|
+
* Reset the initialization state, primarily for testing or hot-reloading.
|
|
2306
|
+
*
|
|
2307
|
+
* Destroys EVERY live instance (#3039), tombstones their model classes, and
|
|
2308
|
+
* stands a fresh default context up in their place.
|
|
876
2309
|
*/
|
|
877
2310
|
declare function resetJsBao(): Promise<void>;
|
|
878
2311
|
|
|
@@ -1103,4 +2536,790 @@ declare function syncModelMeta(yDoc: Y.Doc, modelName: string, schema: ModelSche
|
|
|
1103
2536
|
*/
|
|
1104
2537
|
declare function syncInferredMeta(yDoc: Y.Doc, modelName: string, recordData: Record<string, any>): void;
|
|
1105
2538
|
|
|
1106
|
-
|
|
2539
|
+
/**
|
|
2540
|
+
* The client's epoch-doc observer for format-2 documents (#2816).
|
|
2541
|
+
*
|
|
2542
|
+
* The epoch Y.Doc is no longer the read model — it is a bounded overlay of
|
|
2543
|
+
* this epoch's changes. Whatever lands in it (a peer's update applied by the
|
|
2544
|
+
* sync layer, or this client's own write) has to be folded into the local
|
|
2545
|
+
* merged `records` view, which is what reads come from.
|
|
2546
|
+
*
|
|
2547
|
+
* The observer rides the Yjs transaction for the same reason the server's
|
|
2548
|
+
* projector does: `Transaction.changed` names precisely the overlay keys an
|
|
2549
|
+
* update touched, so a remote update costs one merge-upsert per touched record
|
|
2550
|
+
* instead of a walk of a document that may be 2GB.
|
|
2551
|
+
*/
|
|
2552
|
+
|
|
2553
|
+
/** Whether a top-level key names an application model map. */
|
|
2554
|
+
declare function isFormat2ModelMap(key: string): boolean;
|
|
2555
|
+
/**
|
|
2556
|
+
* Fold every overlay change on `doc` into `store`. Returns the unsubscribe
|
|
2557
|
+
* function — a client calls it when the epoch is sealed and it switches to the
|
|
2558
|
+
* next epoch's document.
|
|
2559
|
+
*/
|
|
2560
|
+
declare function observeFormat2Overlay(doc: Y.Doc, store: Format2RecordStore,
|
|
2561
|
+
/**
|
|
2562
|
+
* Called after each folded record, so the host can refresh derived state
|
|
2563
|
+
* (the per-model query projection) for exactly what changed.
|
|
2564
|
+
*/
|
|
2565
|
+
onApplied?: (modelName: string, recordId: string) => void): () => void;
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* Following a seal onto a fresh overlay (#2816, behavior 10 — client half).
|
|
2569
|
+
*
|
|
2570
|
+
* When the room seals epoch E it replaces its overlay with an empty one, so a
|
|
2571
|
+
* client's further DELTAS against the sealed overlay cannot be integrated
|
|
2572
|
+
* there. The obvious answer — resync the whole document — is the wrong one on
|
|
2573
|
+
* a large document: a state-complete sync of the sealed overlay would seed
|
|
2574
|
+
* E+1 with everything E held, the new epoch would re-cross the rotation
|
|
2575
|
+
* threshold at once, and the overlay would never actually be bounded. That is
|
|
2576
|
+
* precisely why automatic rotation stayed opt-in until this path existed.
|
|
2577
|
+
*
|
|
2578
|
+
* What a client actually owes the new epoch is narrow: the writes the server
|
|
2579
|
+
* has not acknowledged. Everything acknowledged is already in the server's
|
|
2580
|
+
* `records` table — the sealed overlay was archived and folded — and in the
|
|
2581
|
+
* client's own merged view, so re-sending it buys nothing and costs an epoch.
|
|
2582
|
+
*
|
|
2583
|
+
* The owed writes are carried as OVERLAY entries taken from the sealed
|
|
2584
|
+
* document rather than rebuilt from the merged view, because the overlay is
|
|
2585
|
+
* the only place their exact shape survives: an explicit `null` unset, a
|
|
2586
|
+
* stringset member tombstone, and the difference between a patch and a
|
|
2587
|
+
* `_replace` create all vanish once a row has been materialized.
|
|
2588
|
+
*
|
|
2589
|
+
* They are restricted to the fields the unacked ops touched, so replaying them
|
|
2590
|
+
* cannot clobber a peer's concurrent write to another field of the same
|
|
2591
|
+
* record. A create is the exception: `_replace` discards the base row, so it
|
|
2592
|
+
* is carried whole or not at all.
|
|
2593
|
+
*/
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* The name a client's persisted overlay is stored under.
|
|
2597
|
+
*
|
|
2598
|
+
* Epoch-scoped so a restart restores the CURRENT epoch's overlay — which still
|
|
2599
|
+
* holds its unacked writes — and never a sealed one, whose content the server
|
|
2600
|
+
* has already folded away. It also bounds what an IndexedDB reset costs: one
|
|
2601
|
+
* epoch to re-sync, not the whole document.
|
|
2602
|
+
*/
|
|
2603
|
+
declare function format2EpochDocName(docId: string, epoch: number): string;
|
|
2604
|
+
/** One record's owed overlay, ready to be written on the new epoch's document. */
|
|
2605
|
+
interface CarriedOverlay {
|
|
2606
|
+
model: string;
|
|
2607
|
+
entry: OverlayRecordEntry;
|
|
2608
|
+
}
|
|
2609
|
+
/** Reads a record's whole overlay entry out of the epoch being left behind. */
|
|
2610
|
+
type OverlayReader = (model: string, recordId: string) => OverlayRecordEntry;
|
|
2611
|
+
/**
|
|
2612
|
+
* The overlay a client carries into the next epoch: one entry per record its
|
|
2613
|
+
* unacked ops touched, in the local order those ops were written.
|
|
2614
|
+
*
|
|
2615
|
+
* `ops` is the durable `_pending_ops` log — already exactly the unacked ones,
|
|
2616
|
+
* since an `update.ack` prunes at its contiguous high-water mark.
|
|
2617
|
+
*/
|
|
2618
|
+
declare function carryUnackedOverlay(ops: PendingOp[], readOverlay: OverlayReader, options?: {
|
|
2619
|
+
/**
|
|
2620
|
+
* Records whose tombstone must not travel (#2816, behavior 17): the
|
|
2621
|
+
* client's own delete lost to a write made online after it, and the
|
|
2622
|
+
* lifecycle carryRecord reads off the overlay still records that delete.
|
|
2623
|
+
*/
|
|
2624
|
+
suppressDeletes?: Array<{
|
|
2625
|
+
model: string;
|
|
2626
|
+
recordId: string;
|
|
2627
|
+
}>;
|
|
2628
|
+
}): CarriedOverlay[];
|
|
2629
|
+
/**
|
|
2630
|
+
* Write a carried entry's flat keys onto the new epoch's model map.
|
|
2631
|
+
*
|
|
2632
|
+
* Encoded here rather than through `encodeOverlayMutation` because a carried
|
|
2633
|
+
* entry is already an overlay — its explicit nulls, its member tombstones and
|
|
2634
|
+
* its markers travel verbatim, with no mutation semantics re-derived on top.
|
|
2635
|
+
*/
|
|
2636
|
+
declare function writeCarriedOverlay(modelMap: Y.Map<any>, entry: OverlayRecordEntry): void;
|
|
2637
|
+
|
|
2638
|
+
/**
|
|
2639
|
+
* Switching a connected client's write target across a rotation (#2816,
|
|
2640
|
+
* behavior 10 — client half).
|
|
2641
|
+
*
|
|
2642
|
+
* Sealing epoch E leaves the room writing to an empty overlay. Everything a
|
|
2643
|
+
* client sends afterwards from its E document is a Yjs DELTA whose causal
|
|
2644
|
+
* dependencies the new overlay does not have, so it is parked rather than
|
|
2645
|
+
* integrated — which is why a client has to move to E+1 too.
|
|
2646
|
+
*
|
|
2647
|
+
* Moving means a FRESH document, not a resync of the old one. A state-complete
|
|
2648
|
+
* sync of the sealed overlay would seed E+1 with everything E held: the new
|
|
2649
|
+
* epoch would re-cross the rotation threshold immediately, and rotation would
|
|
2650
|
+
* never bound anything. The only thing worth carrying across is what the
|
|
2651
|
+
* server has not acknowledged — see `format2EpochHandoff`.
|
|
2652
|
+
*
|
|
2653
|
+
* The orchestration lives behind a host interface because the pieces it moves
|
|
2654
|
+
* belong to three different owners (the document manager holds the Y.Doc and
|
|
2655
|
+
* its persistence, the ORM holds the record store, the transport holds the
|
|
2656
|
+
* socket) and because the ordering — carry, install, resync — is the behavior.
|
|
2657
|
+
*/
|
|
2658
|
+
|
|
2659
|
+
/** What following a seal needs from the client that owns the document. */
|
|
2660
|
+
interface EpochRotationHost {
|
|
2661
|
+
/** The overlay the client writes to now, or `null` when it holds none. */
|
|
2662
|
+
overlayDoc(): Y.Doc | null;
|
|
2663
|
+
/** The epoch the client is on; `0` when it has never followed one. */
|
|
2664
|
+
epoch(): number;
|
|
2665
|
+
/** The durable, still-unacknowledged local writes, oldest first. */
|
|
2666
|
+
pendingOps(): PendingOp[];
|
|
2667
|
+
/**
|
|
2668
|
+
* Decide which of those writes still stand (#2816, behavior 17).
|
|
2669
|
+
*
|
|
2670
|
+
* Set only by a client that is REJOINING after being away: the sealed
|
|
2671
|
+
* overlays it just applied say which records and fields were written online
|
|
2672
|
+
* meanwhile, so an offline write can be dropped where it clearly lost rather
|
|
2673
|
+
* than silently overwriting the newer value. A client following an ordinary
|
|
2674
|
+
* seal has nothing to resolve — it was there the whole time — and leaves
|
|
2675
|
+
* this unset, which carries every unacked write exactly as before.
|
|
2676
|
+
*/
|
|
2677
|
+
resolveReplay?(ops: PendingOp[]): {
|
|
2678
|
+
ops: PendingOp[];
|
|
2679
|
+
suppressDeletes: Array<{
|
|
2680
|
+
model: string;
|
|
2681
|
+
recordId: string;
|
|
2682
|
+
}>;
|
|
2683
|
+
};
|
|
2684
|
+
/**
|
|
2685
|
+
* Drop whatever outbound frames are still queued for this document.
|
|
2686
|
+
*
|
|
2687
|
+
* They are deltas against the overlay the room has just replaced, so it
|
|
2688
|
+
* cannot integrate them: it withholds the acknowledgement for the whole
|
|
2689
|
+
* frame they are merged into — including any fresh write that rode along —
|
|
2690
|
+
* and asks for a resync. Nothing is lost by dropping them, because their
|
|
2691
|
+
* content is either already durable or recorded in `_pending_ops`, which is
|
|
2692
|
+
* exactly what this move carries across.
|
|
2693
|
+
*/
|
|
2694
|
+
discardQueuedFrames(): void;
|
|
2695
|
+
/**
|
|
2696
|
+
* Adopt `doc` as the overlay for `epoch`: rebind the readers, re-point the
|
|
2697
|
+
* persistence at the epoch-scoped name, and record the new mark.
|
|
2698
|
+
*
|
|
2699
|
+
* `restorePending: false` asks the bind NOT to write the unacknowledged
|
|
2700
|
+
* writes back into the document. Set only by a returning client's deferred
|
|
2701
|
+
* move: those writes have not been resolved yet, and restoring them here
|
|
2702
|
+
* would put them in the epoch's own Y.Map keys before the epoch's content
|
|
2703
|
+
* arrives — the very race the deferral exists to avoid.
|
|
2704
|
+
*/
|
|
2705
|
+
install(doc: Y.Doc, epoch: number, options?: {
|
|
2706
|
+
restorePending?: boolean;
|
|
2707
|
+
}): Promise<void>;
|
|
2708
|
+
/** Sync the fresh overlay with the room, which pushes what was carried. */
|
|
2709
|
+
resync(): Promise<void>;
|
|
2710
|
+
/**
|
|
2711
|
+
* Run the move with this document's local writes held back.
|
|
2712
|
+
*
|
|
2713
|
+
* The move destroys the sealed overlay once the fresh one is bound, so a
|
|
2714
|
+
* write that slipped in between reading what is owed and rebinding the
|
|
2715
|
+
* readers would land its overlay entry in the doomed document — and could
|
|
2716
|
+
* never be recovered, since `_pending_ops` records which fields a write
|
|
2717
|
+
* touched, not the values. Optional so a host that owns no writes (a test
|
|
2718
|
+
* driving the state machine) need not provide one.
|
|
2719
|
+
*/
|
|
2720
|
+
hold?<T>(run: () => Promise<T>): Promise<T>;
|
|
2721
|
+
}
|
|
2722
|
+
/** Why a client did or did not move. */
|
|
2723
|
+
type EpochRotationOutcome = {
|
|
2724
|
+
moved: true;
|
|
2725
|
+
epoch: number;
|
|
2726
|
+
carried: number;
|
|
2727
|
+
/**
|
|
2728
|
+
* The replay this move DEFERRED (#2816, behavior 17).
|
|
2729
|
+
*
|
|
2730
|
+
* Present only on a returning client's move: the owed writes have been
|
|
2731
|
+
* read off the sealed overlay but not resolved or written anywhere,
|
|
2732
|
+
* because the epoch being joined is open and its own content — the rest
|
|
2733
|
+
* of the online side of every conflict — arrives on the sync this move
|
|
2734
|
+
* asked for. The caller runs this once that has landed; it resolves
|
|
2735
|
+
* against the evidence by then complete, and writes the survivors onto
|
|
2736
|
+
* the live overlay. Returns how many records it carried.
|
|
2737
|
+
*/
|
|
2738
|
+
completeReplay?: () => Promise<number>;
|
|
2739
|
+
} | {
|
|
2740
|
+
moved: false;
|
|
2741
|
+
reason:
|
|
2742
|
+
/** Not a large document — nothing here applies. */
|
|
2743
|
+
"not-large"
|
|
2744
|
+
/** The client has never followed an epoch; it simply joins this one. */
|
|
2745
|
+
| "adopted"
|
|
2746
|
+
/** A duplicate or late notice of an epoch already left behind. */
|
|
2747
|
+
| "already-current"
|
|
2748
|
+
/** Epochs were missed: catching up is the fast-forward path's job. */
|
|
2749
|
+
| "behind";
|
|
2750
|
+
};
|
|
2751
|
+
/**
|
|
2752
|
+
* Move the client onto `nextEpoch`.
|
|
2753
|
+
*
|
|
2754
|
+
* Only ever moves one epoch forward from where the client is. A larger jump
|
|
2755
|
+
* means the client missed seals — its merged view is behind by whole sealed
|
|
2756
|
+
* overlays, which have to be applied in order before it may write again, so it
|
|
2757
|
+
* is reported rather than papered over.
|
|
2758
|
+
*/
|
|
2759
|
+
declare function followEpoch(host: EpochRotationHost, nextEpoch: number, options?: {
|
|
2760
|
+
/**
|
|
2761
|
+
* Allow a move of more than one epoch.
|
|
2762
|
+
*
|
|
2763
|
+
* Only ever set by the fast-forward path (behavior 12), which has just
|
|
2764
|
+
* applied every sealed overlay in between: the epochs are no longer
|
|
2765
|
+
* missing, so the jump skips nothing. Without that, a jump means the
|
|
2766
|
+
* client's merged view has holes in it and the move is refused.
|
|
2767
|
+
*/
|
|
2768
|
+
allowJump?: boolean;
|
|
2769
|
+
/**
|
|
2770
|
+
* Hold the owed writes back until the epoch being joined has been synced
|
|
2771
|
+
* (#2816, behavior 17).
|
|
2772
|
+
*
|
|
2773
|
+
* Honored only where there is something to resolve — a returning client
|
|
2774
|
+
* with a conflict ledger, i.e. a host that supplies `resolveReplay`. A
|
|
2775
|
+
* client that was present through the seal has no online side to compare
|
|
2776
|
+
* with, so it carries at once exactly as before.
|
|
2777
|
+
*/
|
|
2778
|
+
deferCarry?: boolean;
|
|
2779
|
+
}): Promise<EpochRotationOutcome>;
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* Catching a client up on the sealed overlays it missed (#2816, behavior 12).
|
|
2783
|
+
*
|
|
2784
|
+
* A client that was away while the room rotated is behind by whole epochs: its
|
|
2785
|
+
* merged view is missing every write since it left, and its own overlay
|
|
2786
|
+
* belongs to an epoch the room archived. It does NOT need the document back,
|
|
2787
|
+
* though. The sealed overlays between the epoch it holds and the one the room
|
|
2788
|
+
* is on are exactly the changes it missed, so applying them in order — each a
|
|
2789
|
+
* bounded, rotation-sized artifact — converges its merged view on the server's
|
|
2790
|
+
* `records` table without a base ever crossing the wire.
|
|
2791
|
+
*
|
|
2792
|
+
* Two halves, deliberately separate:
|
|
2793
|
+
*
|
|
2794
|
+
* - {@link planFastForward} decides whether the chain can be trusted, from the
|
|
2795
|
+
* handshake alone and before anything is downloaded. A gap, a pruned
|
|
2796
|
+
* archive, or an epoch flagged `baseDiscontinuity` all mean the sum of the
|
|
2797
|
+
* overlays is not the document, and the honest answer is to reload from a
|
|
2798
|
+
* snapshot rather than converge on a state that never existed.
|
|
2799
|
+
* - {@link applyFastForward} runs the plan. It knows nothing about record
|
|
2800
|
+
* stores or sockets: the host fetches an archive and folds a decoded overlay,
|
|
2801
|
+
* which is what makes the ordering testable without either.
|
|
2802
|
+
*
|
|
2803
|
+
* The epoch mark is the caller's to move, and only after the whole chain has
|
|
2804
|
+
* landed: applying an overlay is idempotent at the record level, so a chain
|
|
2805
|
+
* that fails halfway is simply run again.
|
|
2806
|
+
*/
|
|
2807
|
+
|
|
2808
|
+
/** One sealed epoch as the handshake describes it. */
|
|
2809
|
+
interface SealedEpochChainEntry {
|
|
2810
|
+
epoch: number;
|
|
2811
|
+
/** Reserved (#2816): the next base is not this epoch's overlay applied. */
|
|
2812
|
+
baseDiscontinuity?: boolean;
|
|
2813
|
+
/**
|
|
2814
|
+
* Whatever the host needs to read this epoch's archive, opaque here. Absent
|
|
2815
|
+
* when retention has already pruned it.
|
|
2816
|
+
*/
|
|
2817
|
+
download?: unknown;
|
|
2818
|
+
}
|
|
2819
|
+
/** One archive to apply. */
|
|
2820
|
+
interface FastForwardStep {
|
|
2821
|
+
epoch: number;
|
|
2822
|
+
download: unknown;
|
|
2823
|
+
}
|
|
2824
|
+
/** Why a chain cannot be trusted. */
|
|
2825
|
+
type FastForwardRefusal =
|
|
2826
|
+
/** The chain skips an epoch: the client cannot know what happened in it. */
|
|
2827
|
+
"gap"
|
|
2828
|
+
/** An epoch declares its base is not the previous overlay applied. */
|
|
2829
|
+
| "discontinuity"
|
|
2830
|
+
/** The archive is gone — pruned once a snapshot covered it. */
|
|
2831
|
+
| "unavailable";
|
|
2832
|
+
type FastForwardPlan = {
|
|
2833
|
+
kind: "none";
|
|
2834
|
+
reason: "current" | "adopt";
|
|
2835
|
+
} | {
|
|
2836
|
+
kind: "apply";
|
|
2837
|
+
from: number;
|
|
2838
|
+
to: number;
|
|
2839
|
+
steps: FastForwardStep[];
|
|
2840
|
+
} | {
|
|
2841
|
+
kind: "reload";
|
|
2842
|
+
reason: FastForwardRefusal;
|
|
2843
|
+
epoch: number;
|
|
2844
|
+
};
|
|
2845
|
+
/**
|
|
2846
|
+
* Decide how a client on `current` reaches `target`.
|
|
2847
|
+
*
|
|
2848
|
+
* The chain runs from the epoch the client HOLDS, not from the one after it:
|
|
2849
|
+
* writes landed in that epoch after this client went away, and its archive is
|
|
2850
|
+
* the only place they exist now. The target epoch itself is open — the client
|
|
2851
|
+
* reaches that one by syncing, as it always did.
|
|
2852
|
+
*/
|
|
2853
|
+
declare function planFastForward(options: {
|
|
2854
|
+
current: number;
|
|
2855
|
+
target: number;
|
|
2856
|
+
sealed: SealedEpochChainEntry[];
|
|
2857
|
+
}): FastForwardPlan;
|
|
2858
|
+
/** What running a plan needs from the client around it. */
|
|
2859
|
+
interface FastForwardHost {
|
|
2860
|
+
/** Read one sealed epoch's archived overlay. */
|
|
2861
|
+
fetchArchive(step: FastForwardStep): Promise<Uint8Array>;
|
|
2862
|
+
/**
|
|
2863
|
+
* Fold one decoded overlay into the merged view. Awaited, so a host whose
|
|
2864
|
+
* store commits asynchronously cannot have two epochs in flight at once.
|
|
2865
|
+
*/
|
|
2866
|
+
applyOverlay(doc: Y.Doc, epoch: number): void | Promise<void>;
|
|
2867
|
+
/** Called after each epoch lands, for progress reporting. */
|
|
2868
|
+
onEpochApplied?(epoch: number, index: number, total: number): void;
|
|
2869
|
+
}
|
|
2870
|
+
/** What the whole catch-up needs, beyond reading and folding archives. */
|
|
2871
|
+
interface FastForwardRunHost extends FastForwardHost {
|
|
2872
|
+
/**
|
|
2873
|
+
* Run the catch-up with this document's local writes held back.
|
|
2874
|
+
*
|
|
2875
|
+
* A save that resolved its target before the catch-up and lands during it
|
|
2876
|
+
* would write into an overlay the move is about to replace — and
|
|
2877
|
+
* `_pending_ops` records which fields a write touched, not the values, so
|
|
2878
|
+
* nothing could reconstruct it.
|
|
2879
|
+
*/
|
|
2880
|
+
hold<T>(run: () => Promise<T>): Promise<T>;
|
|
2881
|
+
/**
|
|
2882
|
+
* Move onto `target`: install a fresh overlay for it, carrying whatever the
|
|
2883
|
+
* server has not acknowledged, and resync. Called only once the whole chain
|
|
2884
|
+
* has landed, so the jump is no longer a jump over anything unknown.
|
|
2885
|
+
*/
|
|
2886
|
+
move(target: number): Promise<void>;
|
|
2887
|
+
}
|
|
2888
|
+
/** How a catch-up ended. */
|
|
2889
|
+
type FastForwardResult = {
|
|
2890
|
+
outcome: "none";
|
|
2891
|
+
reason: "current" | "adopt";
|
|
2892
|
+
} | {
|
|
2893
|
+
outcome: "caught-up";
|
|
2894
|
+
applied: number[];
|
|
2895
|
+
epoch: number;
|
|
2896
|
+
} | {
|
|
2897
|
+
outcome: "reload";
|
|
2898
|
+
/** `failed` is a verified chain that could not be read. */
|
|
2899
|
+
reason: FastForwardRefusal | "failed";
|
|
2900
|
+
epoch: number;
|
|
2901
|
+
/**
|
|
2902
|
+
* What stopped a `failed` chain. A document that quietly stops syncing
|
|
2903
|
+
* is the hardest kind of bug to diagnose, so the cause travels with the
|
|
2904
|
+
* refusal instead of being swallowed.
|
|
2905
|
+
*/
|
|
2906
|
+
error?: unknown;
|
|
2907
|
+
};
|
|
2908
|
+
/**
|
|
2909
|
+
* Verify the chain, apply it, and move onto the room's epoch.
|
|
2910
|
+
*
|
|
2911
|
+
* The order is the behavior: nothing is downloaded until the chain is known to
|
|
2912
|
+
* be complete, and the epoch is not moved until every archive in it has been
|
|
2913
|
+
* folded. A chain that fails halfway leaves the client exactly where it was —
|
|
2914
|
+
* behind, readable, and told to reload — rather than half-caught-up on an
|
|
2915
|
+
* epoch it would then write into.
|
|
2916
|
+
*/
|
|
2917
|
+
declare function runFastForward(host: FastForwardRunHost, options: {
|
|
2918
|
+
current: number;
|
|
2919
|
+
target: number;
|
|
2920
|
+
sealed: SealedEpochChainEntry[];
|
|
2921
|
+
}): Promise<FastForwardResult>;
|
|
2922
|
+
/**
|
|
2923
|
+
* Apply a verified chain, oldest epoch first, and report what landed.
|
|
2924
|
+
*
|
|
2925
|
+
* Strictly sequential: overlays are state deltas over one another, so applying
|
|
2926
|
+
* a later one before an earlier one would let a stale value win. A failure
|
|
2927
|
+
* propagates with the epochs before it already folded — harmless, because the
|
|
2928
|
+
* caller has not moved the epoch mark and re-applying is idempotent.
|
|
2929
|
+
*/
|
|
2930
|
+
declare function applyFastForward(plan: Extract<FastForwardPlan, {
|
|
2931
|
+
kind: "apply";
|
|
2932
|
+
}>, host: FastForwardHost): Promise<number[]>;
|
|
2933
|
+
|
|
2934
|
+
/**
|
|
2935
|
+
* Opening a large document with nothing local to start from (#2816,
|
|
2936
|
+
* behavior 15).
|
|
2937
|
+
*
|
|
2938
|
+
* A fresh client is not "behind": it holds no overlay, so there is nothing to
|
|
2939
|
+
* catch up from and the fast-forward planner rightly says "adopt". Under
|
|
2940
|
+
* epochs that is not enough. The epoch it would adopt holds only the changes
|
|
2941
|
+
* since the last rotation — everything older lives in the server's `records`
|
|
2942
|
+
* table and in the base snapshot in R2 — so a client that just joined would
|
|
2943
|
+
* show an almost-empty document, and, because a create is decided against the
|
|
2944
|
+
* merged view, would `_replace` records that still exist.
|
|
2945
|
+
*
|
|
2946
|
+
* So a cold start on a document that has ever rotated rebuilds a base first:
|
|
2947
|
+
*
|
|
2948
|
+
* - the latest completed snapshot, then the overlays sealed since — one base
|
|
2949
|
+
* plus a bounded chain, whatever the document's age;
|
|
2950
|
+
* - or, before any build has completed, the sealed overlays from the very
|
|
2951
|
+
* first epoch, which retention keeps precisely until a snapshot covers them;
|
|
2952
|
+
* - or nothing, when neither is available — which is reported rather than
|
|
2953
|
+
* papered over, because opening a document with holes in it is worse than
|
|
2954
|
+
* not opening it.
|
|
2955
|
+
*/
|
|
2956
|
+
|
|
2957
|
+
/** How a document with no local state should be opened. */
|
|
2958
|
+
type ColdStartPlan =
|
|
2959
|
+
/** The document already follows an epoch: the catch-up path owns it. */
|
|
2960
|
+
{
|
|
2961
|
+
kind: "catch-up";
|
|
2962
|
+
}
|
|
2963
|
+
/** Nothing was ever archived — the current overlay IS the document. */
|
|
2964
|
+
| {
|
|
2965
|
+
kind: "join";
|
|
2966
|
+
}
|
|
2967
|
+
/** Materialize this snapshot, then apply the overlays sealed since. */
|
|
2968
|
+
| {
|
|
2969
|
+
kind: "snapshot";
|
|
2970
|
+
base: number;
|
|
2971
|
+
}
|
|
2972
|
+
/** No snapshot yet: the sealed chain from `base` is the document. */
|
|
2973
|
+
| {
|
|
2974
|
+
kind: "overlays";
|
|
2975
|
+
base: number;
|
|
2976
|
+
}
|
|
2977
|
+
/** Neither a base nor a complete chain is available. */
|
|
2978
|
+
| {
|
|
2979
|
+
kind: "unavailable";
|
|
2980
|
+
reason: "no-base";
|
|
2981
|
+
};
|
|
2982
|
+
/**
|
|
2983
|
+
* Decide how to open a document the client has no epoch mark for.
|
|
2984
|
+
*
|
|
2985
|
+
* A snapshot is preferred whenever the client can chain forward from it: one
|
|
2986
|
+
* base plus the overlays after it, rather than replaying every epoch since the
|
|
2987
|
+
* document was created — the cost this epic exists to remove. When the chain
|
|
2988
|
+
* above the snapshot is broken but the whole chain from the first epoch is
|
|
2989
|
+
* intact, that chain is used instead; it says the same thing, more expensively.
|
|
2990
|
+
*/
|
|
2991
|
+
declare function planColdStart(input: {
|
|
2992
|
+
/** The epoch this client's overlay belongs to; 0 when it has none. */
|
|
2993
|
+
held: number;
|
|
2994
|
+
/** The epoch the room reports. */
|
|
2995
|
+
reported: number;
|
|
2996
|
+
sealed: readonly SealedEpochChainEntry[];
|
|
2997
|
+
/** The newest completed snapshot the handshake offered, if any. */
|
|
2998
|
+
snapshot?: {
|
|
2999
|
+
epoch: number;
|
|
3000
|
+
} | null;
|
|
3001
|
+
}): ColdStartPlan;
|
|
3002
|
+
/** What running a plan does to the document around it. */
|
|
3003
|
+
interface ColdStartHost {
|
|
3004
|
+
/**
|
|
3005
|
+
* Adopt the reported epoch as the one this document now follows.
|
|
3006
|
+
*
|
|
3007
|
+
* Two callers: the document where nothing was ever archived (the overlay IS
|
|
3008
|
+
* the document), and the end of a successful rebuild. The second is not
|
|
3009
|
+
* optional bookkeeping — the mark is what a later open reads to tell "this
|
|
3010
|
+
* client holds the document as of epoch E" from "this client holds nothing".
|
|
3011
|
+
* Without it a client that has just streamed a whole base presents as fresh
|
|
3012
|
+
* again on its next handshake and rebuilds from the same base.
|
|
3013
|
+
*/
|
|
3014
|
+
join(): void | Promise<void>;
|
|
3015
|
+
/**
|
|
3016
|
+
* Throw away a merged view that cannot be repaired, keeping the writes this
|
|
3017
|
+
* client still owes the server.
|
|
3018
|
+
*/
|
|
3019
|
+
discard(): void | Promise<void>;
|
|
3020
|
+
/** Materialize the base snapshot for `base`. */
|
|
3021
|
+
loadSnapshot(base: number): Promise<void>;
|
|
3022
|
+
/**
|
|
3023
|
+
* Apply the sealed overlays from `base` on and move onto the room's epoch.
|
|
3024
|
+
* Returns whether the document ended up current.
|
|
3025
|
+
*/
|
|
3026
|
+
applyChain(base: number): Promise<boolean>;
|
|
3027
|
+
/** Run the ordinary catch-up for a document that is merely behind. */
|
|
3028
|
+
catchUp(): Promise<boolean>;
|
|
3029
|
+
/**
|
|
3030
|
+
* Join the epoch the room is on, if the rebuild has not already.
|
|
3031
|
+
*
|
|
3032
|
+
* Needed because the base a refused client rebuilds from is usually the
|
|
3033
|
+
* epoch the room is ON — a snapshot built after the last seal has nothing
|
|
3034
|
+
* sealed above it — so the chain is empty and moves nothing. The document
|
|
3035
|
+
* would then keep an overlay belonging to the epoch it was rebuilt out of:
|
|
3036
|
+
* never joining, and blind to every write made since.
|
|
3037
|
+
*/
|
|
3038
|
+
land(): Promise<boolean>;
|
|
3039
|
+
/**
|
|
3040
|
+
* Fold the OPEN epoch's overlay over the base that was just installed.
|
|
3041
|
+
*
|
|
3042
|
+
* A base snapshot is a set of rows, not a diff: every row it carries
|
|
3043
|
+
* REPLACES what the merged view held for that record. The open epoch's
|
|
3044
|
+
* overlay — the changes since the base was cut — is the one part of the
|
|
3045
|
+
* document that does not arrive through the rebuild at all: this client
|
|
3046
|
+
* syncs it as an ordinary Y.Doc, before the load, during it, or after. So
|
|
3047
|
+
* whatever of it had already been folded is overwritten by the load, and a
|
|
3048
|
+
* patch or a delete made in the open epoch would come back as the base's
|
|
3049
|
+
* older row.
|
|
3050
|
+
*
|
|
3051
|
+
* Folding it again at the end costs one walk of a bounded overlay and is
|
|
3052
|
+
* idempotent (overlay entries are state, not operations), which is why it is
|
|
3053
|
+
* done unconditionally rather than only when a race is suspected.
|
|
3054
|
+
*/
|
|
3055
|
+
refoldOverlay?(): void | Promise<void>;
|
|
3056
|
+
/**
|
|
3057
|
+
* Report a base that could not be read. A document that quietly stops
|
|
3058
|
+
* opening is the hardest kind of bug to diagnose, so the cause is handed
|
|
3059
|
+
* back rather than swallowed here.
|
|
3060
|
+
*/
|
|
3061
|
+
onLoadFailed?(error: unknown): void;
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* Run a rebuild plan, and say whether the document came out current.
|
|
3065
|
+
*
|
|
3066
|
+
* `stale` is the whole difference between the two callers. A client opening a
|
|
3067
|
+
* document for the first time has nothing to throw away — and throwing away
|
|
3068
|
+
* anyway would destroy the chunk marks an interrupted load resumes from, which
|
|
3069
|
+
* on a multi-hundred-megabyte base means starting over on every dropped
|
|
3070
|
+
* connection. A client whose sealed-overlay chain was REFUSED does: its rows
|
|
3071
|
+
* are an epoch the room left behind, and a snapshot loaded over them would
|
|
3072
|
+
* leave behind every record deleted since (#2816, behavior 12's close-out).
|
|
3073
|
+
*/
|
|
3074
|
+
declare function runColdStart(host: ColdStartHost, plan: ColdStartPlan, options?: {
|
|
3075
|
+
stale?: boolean;
|
|
3076
|
+
}): Promise<boolean>;
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* Replaying what a client wrote while it was away (#2816, behavior 17 —
|
|
3080
|
+
* amended D1, recency-aware).
|
|
3081
|
+
*
|
|
3082
|
+
* A client reconnecting into a later epoch replays its unacknowledged writes
|
|
3083
|
+
* onto the epoch it lands on. Doing that unconditionally — the original D1 —
|
|
3084
|
+
* silently overwrites whatever anyone else wrote in between, including writes
|
|
3085
|
+
* made days after the offline one. Doing it with per-field timestamps would
|
|
3086
|
+
* mean stamping every field of every record, in the overlay, in `records` and
|
|
3087
|
+
* in every snapshot chunk, for a case that is rare by construction.
|
|
3088
|
+
*
|
|
3089
|
+
* Neither is necessary, because the client already holds both halves of the
|
|
3090
|
+
* evidence by the time it replays:
|
|
3091
|
+
*
|
|
3092
|
+
* - WHAT was written online: the sealed overlays it applies while catching up
|
|
3093
|
+
* are precisely the touched records and fields of the epochs it missed;
|
|
3094
|
+
* - WHEN: an epoch's window runs from the previous epoch's seal to its own,
|
|
3095
|
+
* and the open epoch's ends now.
|
|
3096
|
+
*
|
|
3097
|
+
* So each op's own `ts` — stamped with the client's server-offset-corrected
|
|
3098
|
+
* clock — is placed against the window of the epoch that touched the same
|
|
3099
|
+
* field. Clearly after it, the offline write wins. Clearly before it, the
|
|
3100
|
+
* online write wins and the offline one is DROPPED AND SURFACED rather than
|
|
3101
|
+
* silently applied. Inside the window the order is genuinely unknown, so the
|
|
3102
|
+
* offline write wins and is surfaced too.
|
|
3103
|
+
*
|
|
3104
|
+
* Two rules are not recency-gated:
|
|
3105
|
+
*
|
|
3106
|
+
* - a patch is never replayed onto a record deleted meanwhile — resurrecting
|
|
3107
|
+
* a deleted record is worse than losing an edit to it, whatever the clock
|
|
3108
|
+
* says (a re-create is different: `_replace` states the record exists, so it
|
|
3109
|
+
* is resolved on recency like any other write);
|
|
3110
|
+
* - an op whose base-epoch chain is unavailable (retention pruned it) cannot
|
|
3111
|
+
* be checked at all, so it falls back to plain offline-wins — surfaced as
|
|
3112
|
+
* unverifiable rather than silently trusted.
|
|
3113
|
+
*
|
|
3114
|
+
* And nothing is ever dropped on a clock this client has no reason to trust:
|
|
3115
|
+
* with no measured server offset, "clearly older" degrades to the ambiguous
|
|
3116
|
+
* case, which keeps the write.
|
|
3117
|
+
*/
|
|
3118
|
+
|
|
3119
|
+
/** What one epoch did to one record, from that epoch's sealed overlay. */
|
|
3120
|
+
interface RecordTouch {
|
|
3121
|
+
/** The epoch, for reporting which write the offline one raced. */
|
|
3122
|
+
epoch: number;
|
|
3123
|
+
fields: Set<string>;
|
|
3124
|
+
deleted: boolean;
|
|
3125
|
+
/** A `_replace` create: it replaced the whole row, so it touched every field. */
|
|
3126
|
+
replaced: boolean;
|
|
3127
|
+
}
|
|
3128
|
+
/** The window an epoch's writes fall in: `[start, end]`, inclusive. */
|
|
3129
|
+
interface EpochWindow {
|
|
3130
|
+
/** The previous epoch's seal, or `null` when it is not known. */
|
|
3131
|
+
start: number | null;
|
|
3132
|
+
/** This epoch's seal, or `null` while it is still open. */
|
|
3133
|
+
end: number | null;
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* What the sealed overlays a catch-up applied say about the online side.
|
|
3137
|
+
*
|
|
3138
|
+
* Built as the chain is applied — the overlays are already decoded there — so
|
|
3139
|
+
* conflict detection costs one walk of artifacts the client was downloading
|
|
3140
|
+
* anyway.
|
|
3141
|
+
*/
|
|
3142
|
+
declare class OfflineConflictLedger {
|
|
3143
|
+
/** Latest touch per record per epoch, newest epoch last. */
|
|
3144
|
+
private readonly touches;
|
|
3145
|
+
private readonly sealedAt;
|
|
3146
|
+
private readonly notedEpochs;
|
|
3147
|
+
/** Record the seal times the handshake reported. */
|
|
3148
|
+
noteSealTimes(times: Array<{
|
|
3149
|
+
epoch: number;
|
|
3150
|
+
sealedAt: number | null | undefined;
|
|
3151
|
+
}>): void;
|
|
3152
|
+
/** Note one record's overlay entry from `epoch`. */
|
|
3153
|
+
noteEntry(epoch: number, model: string, entry: OverlayRecordEntry): void;
|
|
3154
|
+
/** Note everything one sealed epoch's decoded overlay touched. */
|
|
3155
|
+
noteOverlay(epoch: number, doc: Y.Doc): void;
|
|
3156
|
+
/**
|
|
3157
|
+
* Whether every epoch from `from` up to (but not including) `to` was applied
|
|
3158
|
+
* through this ledger — the chain an op's conflicts would have to be in.
|
|
3159
|
+
*/
|
|
3160
|
+
covers(from: number, to: number): boolean;
|
|
3161
|
+
/** The window `epoch`'s writes fall in; `end` is `null` while it is open. */
|
|
3162
|
+
window(epoch: number, currentEpoch: number): EpochWindow;
|
|
3163
|
+
/** The latest epoch after `afterEpoch` that touched `field` of a record. */
|
|
3164
|
+
conflictFor(model: string, recordId: string, field: string, afterEpoch: number): RecordTouch | null;
|
|
3165
|
+
/** The latest epoch after `afterEpoch` that touched a record at all. */
|
|
3166
|
+
recordConflict(model: string, recordId: string, afterEpoch: number): RecordTouch | null;
|
|
3167
|
+
/** The latest epoch after `afterEpoch` that DELETED a record. */
|
|
3168
|
+
deletedAfter(model: string, recordId: string, afterEpoch: number): RecordTouch | null;
|
|
3169
|
+
}
|
|
3170
|
+
/** What the app is told about an offline write that did not simply apply. */
|
|
3171
|
+
interface OfflineReplayNotice {
|
|
3172
|
+
model: string;
|
|
3173
|
+
recordId: string;
|
|
3174
|
+
/** Absent for a whole-record outcome (a delete, or a dropped patch). */
|
|
3175
|
+
field?: string;
|
|
3176
|
+
op: PendingOp["op"];
|
|
3177
|
+
outcome: "dropped" | "kept-ambiguous";
|
|
3178
|
+
reason: "outdated" | "record-deleted" | "in-window" | "unverifiable";
|
|
3179
|
+
/** The epoch whose write it raced; `0` when there was no chain to check. */
|
|
3180
|
+
epoch: number;
|
|
3181
|
+
/** The offline write's own time, as the client stamped it. */
|
|
3182
|
+
ts: number;
|
|
3183
|
+
}
|
|
3184
|
+
/** A record whose carried overlay must NOT bring its tombstone along. */
|
|
3185
|
+
interface SuppressedDelete {
|
|
3186
|
+
model: string;
|
|
3187
|
+
recordId: string;
|
|
3188
|
+
}
|
|
3189
|
+
/** What the epoch handoff replays, after recency has had its say. */
|
|
3190
|
+
interface OfflineReplayPlan {
|
|
3191
|
+
/** The surviving ops, in the client's original local order. */
|
|
3192
|
+
ops: PendingOp[];
|
|
3193
|
+
suppressedDeletes: SuppressedDelete[];
|
|
3194
|
+
notices: OfflineReplayNotice[];
|
|
3195
|
+
/**
|
|
3196
|
+
* Ops that survived in PART — the durable `_pending_ops` row still records
|
|
3197
|
+
* the whole write and has to be rewritten to what survived.
|
|
3198
|
+
*
|
|
3199
|
+
* Narrowing an op in memory is not enough. The row carries the mutation the
|
|
3200
|
+
* write made, so a crash before the server acknowledges the replay would
|
|
3201
|
+
* have the restore path put the WHOLE mutation back on the overlay,
|
|
3202
|
+
* including the field this resolution just decided the online side had won.
|
|
3203
|
+
* The write that lost would come back — quietly, and only on the machines
|
|
3204
|
+
* that crashed at the wrong moment.
|
|
3205
|
+
*/
|
|
3206
|
+
narrowed: PendingOp[];
|
|
3207
|
+
/**
|
|
3208
|
+
* Whether a dropped write leaves this client's merged view unable to state
|
|
3209
|
+
* the record correctly (#2816, behavior 17).
|
|
3210
|
+
*
|
|
3211
|
+
* A dropped DELETE is the case: the row was removed locally when the delete
|
|
3212
|
+
* was written, and the online writes folded on top of that absence created
|
|
3213
|
+
* the record again from their own fields alone. The fields the delete
|
|
3214
|
+
* removed live only in the base and the epochs before it, so no artifact
|
|
3215
|
+
* this client still holds can restore them — the merged view has to be
|
|
3216
|
+
* rebuilt from a base before the delete is abandoned.
|
|
3217
|
+
*/
|
|
3218
|
+
rebuildRequired: boolean;
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Decide what a reconnecting client replays onto the epoch it landed on.
|
|
3222
|
+
*
|
|
3223
|
+
* Ops are returned in their original local order — a later op of the same
|
|
3224
|
+
* record must not overtake an earlier one — with their `fields` narrowed to
|
|
3225
|
+
* what survived. An op that lost everything is absent entirely.
|
|
3226
|
+
*/
|
|
3227
|
+
declare function resolveOfflineReplay(options: {
|
|
3228
|
+
ops: PendingOp[];
|
|
3229
|
+
ledger: OfflineConflictLedger;
|
|
3230
|
+
/** The epoch being replayed onto (its window ends now). */
|
|
3231
|
+
currentEpoch: number;
|
|
3232
|
+
now: number;
|
|
3233
|
+
/** Whether the op timestamps were corrected against server time. */
|
|
3234
|
+
clockOffsetKnown?: boolean;
|
|
3235
|
+
}): OfflineReplayPlan;
|
|
3236
|
+
|
|
3237
|
+
/**
|
|
3238
|
+
* The offline write window of a large document (#2816, behavior 18).
|
|
3239
|
+
*
|
|
3240
|
+
* A format-2 client writes while it is offline: the write lands in the merged
|
|
3241
|
+
* view immediately and waits in `_pending_ops` for the reconnect that replays
|
|
3242
|
+
* it onto whatever epoch the room has reached. What makes that replay
|
|
3243
|
+
* resolvable is the sealed-overlay chain back to the epoch the op was written
|
|
3244
|
+
* against — and retention releases an overlay once a snapshot covers it and it
|
|
3245
|
+
* is older than the window W. So a client that has been away longer than W is
|
|
3246
|
+
* writing against a past the server can no longer reconcile it with.
|
|
3247
|
+
*
|
|
3248
|
+
* Past the window the document is READ-ONLY rather than a growing pile of
|
|
3249
|
+
* writes that will be resolved by guesswork: local writes are refused with a
|
|
3250
|
+
* typed error, reads keep answering out of the merged view the client already
|
|
3251
|
+
* holds, and a sync restores writes immediately. Nothing already acked is
|
|
3252
|
+
* touched, and a refused write leaves nothing behind — no merged row, no
|
|
3253
|
+
* pending op — so the refusal cannot itself become a write that replays later.
|
|
3254
|
+
*
|
|
3255
|
+
* The window is the server's number (the same per-app W retention prunes
|
|
3256
|
+
* archives by), delivered on `epoch.info` and persisted locally: the session
|
|
3257
|
+
* that has to enforce it is by definition the one that cannot ask.
|
|
3258
|
+
*/
|
|
3259
|
+
/** The window a deployment gets when it configures nothing. */
|
|
3260
|
+
declare const DEFAULT_OFFLINE_WINDOW_DAYS = 7;
|
|
3261
|
+
/**
|
|
3262
|
+
* The range the window is clamped to — the same 1–14 days as overlay
|
|
3263
|
+
* retention, because the archive chain is exactly what an offline write is
|
|
3264
|
+
* replayed across.
|
|
3265
|
+
*/
|
|
3266
|
+
declare const MIN_OFFLINE_WINDOW_DAYS = 1;
|
|
3267
|
+
declare const MAX_OFFLINE_WINDOW_DAYS = 14;
|
|
3268
|
+
/**
|
|
3269
|
+
* Read a configured window, clamped, with the default for anything
|
|
3270
|
+
* unparseable.
|
|
3271
|
+
*
|
|
3272
|
+
* Clamped rather than refused for the same reason retention clamps: the
|
|
3273
|
+
* alternative to a usable number here is a client that either never goes
|
|
3274
|
+
* read-only or goes read-only immediately, and both are worse than a number
|
|
3275
|
+
* that is merely not the one someone typed.
|
|
3276
|
+
*/
|
|
3277
|
+
declare function configuredOfflineWindowDays(raw: unknown): number;
|
|
3278
|
+
/** Where a document stands relative to its offline write window. */
|
|
3279
|
+
interface OfflineWindowStatus {
|
|
3280
|
+
/** Whether a local write may be accepted now. */
|
|
3281
|
+
writable: boolean;
|
|
3282
|
+
/** The sync this is measured from, or `null` when none was ever recorded. */
|
|
3283
|
+
lastSyncAt: number | null;
|
|
3284
|
+
windowDays: number;
|
|
3285
|
+
windowMs: number;
|
|
3286
|
+
/** How far PAST the window the client is; `0` while it is inside it. */
|
|
3287
|
+
overdueMs: number;
|
|
3288
|
+
}
|
|
3289
|
+
/**
|
|
3290
|
+
* Decide the window from the mark the client persisted.
|
|
3291
|
+
*
|
|
3292
|
+
* A client with no mark is writable: the window measures time since the last
|
|
3293
|
+
* sync, and treating "no mark yet" as "infinitely stale" would strand a
|
|
3294
|
+
* freshly loaded document in read-only with no way out. The mark is written
|
|
3295
|
+
* the first time the client hears from the server, which is before it can
|
|
3296
|
+
* possibly be a week behind.
|
|
3297
|
+
*/
|
|
3298
|
+
declare function offlineWindowStatus(input: {
|
|
3299
|
+
lastSyncAt: number | null | undefined;
|
|
3300
|
+
windowDays?: number | null;
|
|
3301
|
+
now?: number;
|
|
3302
|
+
}): OfflineWindowStatus;
|
|
3303
|
+
/** Raised for a local write on a document past its offline window. */
|
|
3304
|
+
declare class DocumentOfflineWindowError extends Error {
|
|
3305
|
+
readonly code = "DOCUMENT_OFFLINE_WINDOW_EXPIRED";
|
|
3306
|
+
readonly documentId: string;
|
|
3307
|
+
readonly lastSyncAt: number | null;
|
|
3308
|
+
readonly windowDays: number;
|
|
3309
|
+
readonly overdueMs: number;
|
|
3310
|
+
constructor(documentId: string, status: OfflineWindowStatus);
|
|
3311
|
+
}
|
|
3312
|
+
/** The window each large-document write is checked against. */
|
|
3313
|
+
interface OfflineWindowSource {
|
|
3314
|
+
lastSyncAt(): number | null;
|
|
3315
|
+
offlineWindowDays(): number;
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* The one gate every format-2 local write passes.
|
|
3319
|
+
*
|
|
3320
|
+
* Checked BEFORE anything is committed, so a refused write leaves neither a
|
|
3321
|
+
* merged row nor a pending op behind.
|
|
3322
|
+
*/
|
|
3323
|
+
declare function assertWithinOfflineWindow(documentId: string, source: OfflineWindowSource, now?: number): void;
|
|
3324
|
+
|
|
3325
|
+
export { type AddRelationMethod, type AggregationAliasDebugInfo, type AggregationOperation, type AggregationOptions, type AggregationResult, BaseModel, type CarriedOverlay, type ColdStartHost, type ColdStartPlan, DEFAULT_OFFLINE_WINDOW_DAYS, DEFAULT_OVERLAY_TABLES, type DatabaseConfig, DatabaseEngine, type DatabaseEngineType, BrowserDatabaseFactory as DatabaseFactory, type DiscoveredConstraint, type DiscoveredField, type DiscoveredModel, type DiscoveredRelationship, type DiscoveredSchema, DocumentOfflineWindowError, type EpochRotationHost, type EpochRotationOutcome, type EpochWindow, type FastForwardHost, type FastForwardPlan, type FastForwardRefusal, type FastForwardResult, type FastForwardRunHost, type FastForwardStep, type FieldOptions, type FieldType, Format2CompositeFieldError, Format2ModelNotHydratedError, Format2RecordStore, Format2StorageCapabilityError, type Format2StoreSql, type GroupByField, type HasManyMethod, type HasManyThroughMethod, type HydrationPlan, type InferAttrs, type InitJsBaoOptions, type InitJsBaoResult, LogLevel, Logger, MAX_OFFLINE_WINDOW_DAYS, MIN_OFFLINE_WINDOW_DAYS, type ModelConstructor, type ModelOptions, ModelRegistry, OVERLAY_MARKER_DELETED, OVERLAY_MARKER_REPLACE, OfflineConflictLedger, type OfflineReplayNotice, type OfflineReplayPlan, type OfflineWindowStatus, type OverlayMutation, type OverlayReader, type OverlayRecordEntry, type OverlaySql, type OverlayTables, type PaginatedResult, type PaginationOptions, type PendingOp, type PendingOpInput, RecordNotFoundError, type RefersToMethod, type RegisteredModelInfo, type RemoveRelationMethod, SNAPSHOT_COMPRESSED_EXPANSION, SNAPSHOT_STORAGE_EXPANSION, type SQLJSEngineOptions, type Schema, type SealedEpochChainEntry, type SnapshotChunkEntry, SnapshotLoadIntegrityError, type SnapshotLoadProgress, type SnapshotLoadResult, type SnapshotManifest, type SnapshotRow, SqljsEngine, type SqljsEngineOptions, type StorageCapability, StringSet, type StringSetMembership, type SuppressedDelete, type UniqueConstraintConfig, UniqueConstraintViolationError, applyFastForward, applyOverlayMutation, assertNoFormat2CompositeFields, assertWithinOfflineWindow, attachAndRegisterModel, attachSchemaToClass, autoRegisterModel, carryUnackedOverlay, clearMetaSyncCache, completeOverlayEntries, configuredOfflineWindowDays, createModelClass, defineModelSchema, detectEnvironment, discoverModelNames, discoverSchema, encodeOverlayMutation, estimateMaterializedBytes, features, followEpoch, format2ClientDDL, format2EpochDocName, format2StringSetMembers, format2TableNames, generateULID, groupOverlayEntries, inferFieldType, initJsBao, isBrowser, isFormat2ModelMap, isFormat2StringSetYMap, isNode, loadFormat2Snapshot, loadSchemaFromTomlString, materializeOverlayRecord, observeFormat2Overlay, offlineWindowStatus, overlayFieldKey, overlayMarkerKey, overlayMemberKey, parseOverlayKey, planColdStart, planFastForward, planSnapshotHydration, probeStorageCapability, projectOverlayEntry, readOverlayRecord, readSnapshotChunk, registerFunctionDefault, resetJsBao, resolveOfflineReplay, runColdStart, runFastForward, schemaToToml, snapshotChunkPath, syncInferredMeta, syncModelMeta, writeCarriedOverlay };
|