js-bao 0.5.2 → 0.6.1

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/dist/client.d.cts CHANGED
@@ -75,6 +75,357 @@ interface ModelOptions {
75
75
  relationships?: Record<string, RelationshipConfig>;
76
76
  }
77
77
 
78
+ /**
79
+ * Shared format-2 ("large document", #2816) primitives.
80
+ *
81
+ * A format-2 document is no longer "one Y.Doc": the authoritative document is
82
+ * a persisted SQLite `records` table on both ends, and the Y.Doc shrinks to a
83
+ * bounded per-epoch **state overlay** of the records/fields touched during the
84
+ * current epoch. This module owns the overlay's on-the-wire shape so the
85
+ * client write points, the client observer, the server projector, the
86
+ * materializer and the snapshot builder all encode and decode it identically.
87
+ *
88
+ * ## Encoding — flat field-level keys (sponsor amendment, 2026-08-21)
89
+ *
90
+ * Each model map holds FLAT entries, not one nested `Y.Map` per record:
91
+ *
92
+ * ```
93
+ * ⟨recordId⟩/⟨field⟩ → value ordinary field (`null` = unset)
94
+ * ⟨recordId⟩/⟨field⟩/⟨member⟩ → true|false stringset member / tombstone (D3)
95
+ * ⟨recordId⟩/_replace → true discard the base row
96
+ * ⟨recordId⟩/_deleted → true|false record tombstone
97
+ * ```
98
+ *
99
+ * The nested layout cannot work under epochs: two clients that both touch a
100
+ * record ABSENT from the current epoch each mint a fresh `Y.Map` at the same
101
+ * model-map key, and Yjs resolves that parent-key conflict as last-writer-wins,
102
+ * silently dropping the loser's fields. With flat keys, concurrency resolves
103
+ * per field key, so a concurrent create-vs-patch merges to the union of fields
104
+ * with LWW only on genuinely colliding fields.
105
+ *
106
+ * The record's LOGICAL overlay content is unchanged from the approved design —
107
+ * consumers group a record's keys back into a fields object plus markers via
108
+ * {@link groupOverlayEntries} and apply the record-level rules in
109
+ * {@link materializeOverlayRecord}.
110
+ */
111
+
112
+ /** One record's overlay content, regrouped from its flat keys. */
113
+ interface OverlayRecordEntry {
114
+ id: string;
115
+ /** Touched ordinary fields. A `null` value is an explicit unset (RFC 7396). */
116
+ fields: Record<string, unknown>;
117
+ /** Touched stringset members per field: `member → true` add, `false` tombstone. */
118
+ stringSets: Record<string, Record<string, boolean>>;
119
+ /** The create discarded the base row. */
120
+ replace: boolean;
121
+ /** The record is tombstoned. */
122
+ deleted: boolean;
123
+ }
124
+ /** A record mutation expressed in overlay terms, before it is encoded to keys. */
125
+ interface OverlayMutation {
126
+ id: string;
127
+ /**
128
+ * `create` writes `_replace` (the id is absent from the merged view) and
129
+ * clears any prior `_deleted`; `patch` touches only the named fields;
130
+ * `delete` writes the `_deleted` tombstone.
131
+ */
132
+ kind: "create" | "patch" | "delete";
133
+ /** Ordinary field values; `null` is an explicit unset. */
134
+ fields?: Record<string, unknown>;
135
+ /** Per-field stringset deltas: `member → true` add, `false` remove (D3). */
136
+ stringSetDeltas?: Record<string, Record<string, boolean>>;
137
+ }
138
+
139
+ /**
140
+ * Fold a format-2 epoch overlay into a `records` table (#2816).
141
+ *
142
+ * Shared by BOTH ends: the Durable Object's authoritative table and the
143
+ * client's local merged view fold the overlay with this one implementation, so
144
+ * "what the server says the record is" and "what the client reads back" cannot
145
+ * drift.
146
+ *
147
+ * On a format-2 document the SQLite `records` table is the document — the
148
+ * Y.Doc holds only the current epoch's overlay. Every applied update therefore
149
+ * has to reach `records` in the same transaction boundary as the update-log
150
+ * row, for a document that may be 2GB. That rules out read-modify-write of the
151
+ * record: this module expresses each overlay entry as one merge-upsert whose
152
+ * RFC 7396 semantics SQLite's `json_patch` already implements (an explicit
153
+ * `null` deletes the key), plus incremental `stringset_index` maintenance for
154
+ * the member deltas.
155
+ *
156
+ * The semantics are pinned against the shared JS materializer
157
+ * (`materializeOverlayRecord`), which is what the client's merged view folds
158
+ * with — the two must never drift.
159
+ */
160
+
161
+ /**
162
+ * The slice of Cloudflare's `SqlStorage` this module needs. `exec` returns an
163
+ * iterable cursor of rows; a plain array satisfies it too, which is what the
164
+ * hermetic tests bind.
165
+ */
166
+ interface OverlaySql {
167
+ exec(query: string, ...bindings: unknown[]): Iterable<Record<string, any>>;
168
+ }
169
+
170
+ /**
171
+ * The client-side record store for format-2 (large) documents (#2816).
172
+ *
173
+ * A format-2 client no longer reads records out of Y.Maps: the epoch doc holds
174
+ * only what the current epoch touched, so the merged `records` view is the
175
+ * document. The existing `StorageProvider` cannot serve that — it is an async
176
+ * key-value API with no query and no multi-table transaction.
177
+ *
178
+ * This store defines the contract instead:
179
+ *
180
+ * - **reads** come from `records` (folded with the same `projectOverlayEntry`
181
+ * the Durable Object uses, so the two views cannot drift);
182
+ * - **writes** commit the merged row and the `_pending_ops` entry in ONE
183
+ * transaction — the commit barrier a `save()` resolves behind, which is what
184
+ * makes read-your-writes true and makes a crash immediately after the save
185
+ * non-lossy;
186
+ * - **`_pending_ops`** are pruned only at or below the server's `update.ack`
187
+ * high-water mark, never on `ws.send`;
188
+ * - `_epoch` and `_snapshot_load` carry the epoch the client is following and
189
+ * the chunks a cold load has already completed.
190
+ *
191
+ * It is deliberately synchronous over an injected SQL executor: better-sqlite3
192
+ * (Node) drives it directly, and the worker-hosted OPFS engine drives it inside
193
+ * its worker, where a single round-trip commits the whole transaction.
194
+ *
195
+ * ## Document scoping
196
+ *
197
+ * Unlike the Durable Object — one document per DO — a client database can hold
198
+ * several connected documents at once, so every table here is scoped to ONE
199
+ * document: `records`/`stringset_index` get per-document names, and the
200
+ * bookkeeping tables carry a `doc_id` column in their primary key. Without
201
+ * that, two open large documents would read each other's records and one
202
+ * document's `update.ack` would prune the other's unsynced writes.
203
+ */
204
+
205
+ /** A SQL executor that can also run a set of statements as one atomic commit. */
206
+ interface Format2StoreSql extends OverlaySql {
207
+ transaction<T>(fn: () => T): T;
208
+ }
209
+ /** One durable record of a local write, replayed if it is never acked. */
210
+ interface PendingOp {
211
+ seq: number;
212
+ model: string;
213
+ recordId: string;
214
+ op: "create" | "patch" | "delete";
215
+ /** Field names the op touched — the unit amended-D1 conflict detection works on. */
216
+ fields: string[];
217
+ /** Epoch the op was written against, for recency-aware replay. */
218
+ baseEpoch: number;
219
+ /** Server-offset-corrected wall clock at write time. */
220
+ ts: number;
221
+ /**
222
+ * The overlay mutation this op wrote, so the write can be reproduced from
223
+ * the durable log alone — see {@link Format2RecordStore.commitLocalWrite}.
224
+ * `null` only for a row written by a build that did not record it.
225
+ */
226
+ mutation: OverlayMutation | null;
227
+ }
228
+ type PendingOpInput = Omit<PendingOp, "seq" | "mutation"> & {
229
+ seq?: number;
230
+ };
231
+ declare class Format2RecordStore {
232
+ private readonly sql;
233
+ private readonly docId;
234
+ /** This document's merged-view tables (never another document's). */
235
+ private readonly tables;
236
+ /** Models this device holds, or `null` for the whole document. */
237
+ private scope;
238
+ /** Whether {@link scope} has been read from `_epoch` yet. */
239
+ private scopeLoaded;
240
+ constructor(sql: Format2StoreSql, docId: string);
241
+ /** Create the local schema. Safe to call on every open. */
242
+ init(): void;
243
+ /**
244
+ * Record which models this device holds (#2816, Precondition B's fallback).
245
+ *
246
+ * `null` — the normal case — is "the whole document". A list is what a
247
+ * capped cold load leaves behind: the plan
248
+ * {@link planSnapshotHydration} made, persisted so it outlives the load. It
249
+ * has to be durable, because a restart reconnects and starts folding the
250
+ * epoch overlay again, and without the scope that fold would quietly
251
+ * repopulate a skipped model with the handful of records the epoch touched.
252
+ */
253
+ setHydrationScope(models: readonly string[] | null): void;
254
+ /** The models this device holds, or `null` when it holds the document. */
255
+ hydrationScope(): string[] | null;
256
+ /** Whether this device holds `modelName` at all. */
257
+ isHydrated(modelName: string): boolean;
258
+ /** Refuse a model this device does not hold, rather than answering partly. */
259
+ private assertHydrated;
260
+ read(modelName: string, recordId: string): Record<string, any> | null;
261
+ readAll(modelName: string): Array<Record<string, any>>;
262
+ /**
263
+ * The id of the first record whose fields all match — the merged-view answer
264
+ * to `upsertOn` resolution and to an optimistic unique-constraint check. A
265
+ * base-only holder is found here and nowhere in the epoch doc, which is the
266
+ * whole point.
267
+ */
268
+ findIdByFields(modelName: string, fields: string[], values: unknown[]): string | null;
269
+ findIdByField(modelName: string, field: string, value: unknown): string | null;
270
+ /**
271
+ * Every record id a model holds in the merged view, ids only.
272
+ *
273
+ * The derived query tables are rebuilt from this on connect, and a rebuild
274
+ * has no reason to carry each record's whole `_data` through memory on the
275
+ * way.
276
+ */
277
+ recordIds(modelName: string): string[];
278
+ /**
279
+ * One PAGE of a model's record ids, in id order, after `after`.
280
+ *
281
+ * The whole-model list above is a JavaScript array of every id, which on the
282
+ * documents this epic exists for is millions of strings held at once —
283
+ * before a single row has been projected. A keyset page costs the same per
284
+ * row and bounds what is resident, so walking a model is a walk rather than
285
+ * an allocation (#2816).
286
+ */
287
+ recordIdsAfter(modelName: string, after: string | null, limit: number): string[];
288
+ /**
289
+ * Fold an overlay entry that arrived from the server.
290
+ *
291
+ * A model outside this device's hydration scope is DROPPED here rather than
292
+ * folded: the device declined that model because it has no room for it, and
293
+ * a row of it landing anyway would turn "this model is not held here" into
294
+ * "this model holds one record" — a merged view that reads like the document
295
+ * and is not it.
296
+ */
297
+ applyRemote(modelName: string, entry: OverlayRecordEntry): void;
298
+ /**
299
+ * Run `fn` as ONE commit. A catch-up fold of a whole overlay is a single
300
+ * transaction rather than one per record — the difference between one commit
301
+ * and thousands.
302
+ */
303
+ transaction<T>(fn: () => T): T;
304
+ /**
305
+ * Commit a LOCAL write: the merged row, the pending op, and the OVERLAY
306
+ * MUTATION that op stands for all land together, or none of them does.
307
+ * The returned sequence is what an `update.ack` later prunes against.
308
+ *
309
+ * The mutation is stored, not just the field names it touched, because the
310
+ * merged row cannot reproduce it: materializing a row loses the difference
311
+ * between a patch and a `_replace` create, turns an explicit `null` unset
312
+ * into an absent key, and drops stringset member tombstones. Those shapes
313
+ * survive only in the overlay — and the overlay's own persistence is a Yjs
314
+ * update flushed asynchronously AFTER this commit, so a crash in between
315
+ * would otherwise leave a pending op whose write nothing could reconstruct:
316
+ * the client would claim its sequence on the next catch-up frame, be acked
317
+ * for a write it never sent, and drop it.
318
+ */
319
+ commitLocalWrite(modelName: string, mutation: OverlayMutation, pending: PendingOpInput): number;
320
+ /** The next local sequence: one past the highest ever issued. */
321
+ nextSeq(): number;
322
+ /**
323
+ * The highest sequence this client has ever committed locally — what an
324
+ * outbound update frame claims, and what an `update.ack` has to reach before
325
+ * the document has nothing unsynced left.
326
+ */
327
+ highestLocalSeq(): number;
328
+ pendingOps(): PendingOp[];
329
+ /**
330
+ * Prune at or below the server's contiguous-sequence high-water mark.
331
+ * A lower (out-of-order or replayed) ack never lowers the mark and never
332
+ * resurrects a pruned op.
333
+ */
334
+ prunePendingOps(maxContiguousSeq: number): void;
335
+ ackedSeq(): number;
336
+ epoch(): number;
337
+ setEpoch(epoch: number): void;
338
+ lastSyncAt(): number | null;
339
+ setLastSyncAt(at: number): void;
340
+ /**
341
+ * The offline write window this document is held to (#2816, behavior 18),
342
+ * as the server last reported it — the deployment default until one has.
343
+ */
344
+ offlineWindowDays(): number;
345
+ /**
346
+ * Record that this client was in touch with the server, and (when the
347
+ * server said so) the window it is held to.
348
+ *
349
+ * Persisted together because the session that enforces the window is the one
350
+ * that cannot ask for it: a client that starts offline reads both back out
351
+ * of its own database.
352
+ */
353
+ noteSync(at: number, windowDays?: number | null): void;
354
+ /**
355
+ * Record the window without claiming a sync (#2816, behavior 18).
356
+ *
357
+ * The handshake carries the window whatever else it says, including to a
358
+ * client that is behind by whole epochs and cannot act on the rest of the
359
+ * frame. Taking the number is right there; moving the mark it is measured
360
+ * FROM is not — a frame is proof of contact, not proof that this client's
361
+ * merged view is the room's state, and a document past its window would
362
+ * become writable again on the strength of a handshake whose catch-up then
363
+ * failed.
364
+ */
365
+ noteOfflineWindow(windowDays: number | null | undefined): void;
366
+ /**
367
+ * How far this client's clock is behind the server's, in milliseconds
368
+ * (#2816, behavior 17).
369
+ *
370
+ * Zero until one has been measured — which is why {@link clockOffsetKnown}
371
+ * exists separately: a measured zero and never having measured are the same
372
+ * number and very different facts, and the replay resolver refuses to call a
373
+ * write "clearly older" on the second.
374
+ */
375
+ clockOffset(): number;
376
+ /** Whether the offset above was ever measured against a server. */
377
+ clockOffsetKnown(): boolean;
378
+ /** Record the offset measured against server time while connected. */
379
+ noteClockOffset(offsetMs: number): void;
380
+ /** Now, on the server's clock as far as this client can tell. */
381
+ correctedNow(now?: number): number;
382
+ /**
383
+ * Forget specific pending ops (#2816, behavior 17).
384
+ *
385
+ * The one caller is a replay whose recency resolution DROPPED them: the
386
+ * online write won, so the op must never be replayed — and leaving it in the
387
+ * log would have the next epoch move carry it anyway, and would report the
388
+ * document as holding unsynced changes for good. The merged row it wrote
389
+ * stays: it is what the client is showing, and the next update from the
390
+ * server corrects it.
391
+ */
392
+ forgetPendingOps(seqs: number[]): void;
393
+ /**
394
+ * Rewrite pending ops that survived a replay resolution only in PART
395
+ * (#2816, behavior 17).
396
+ *
397
+ * The same caller as {@link forgetPendingOps}, for the ops it narrowed
398
+ * rather than dropped: a patch that touched two fields, one of which an
399
+ * online write clearly beat. The row must be rewritten to the surviving
400
+ * delta — its field list AND the mutation it stands for — because the
401
+ * mutation is what the restore path puts back on the overlay after a crash,
402
+ * and putting the whole one back would resurrect exactly the field this
403
+ * resolution decided against.
404
+ */
405
+ narrowPendingOps(ops: Array<Pick<PendingOp, "seq" | "fields" | "mutation">>): void;
406
+ completedChunks(buildId: string): string[];
407
+ markChunkComplete(buildId: string, chunkKey: string): void;
408
+ /**
409
+ * Throw away a merged view that cannot be repaired (#2816, behavior 12).
410
+ *
411
+ * The one caller is a client whose sealed-overlay chain was refused: the
412
+ * rows it holds are the state of an epoch the room has left behind, and no
413
+ * available artifact turns them into the current state. It reloads from a
414
+ * base snapshot instead — and a snapshot is a set of rows, not a diff, so
415
+ * loading it over these would leave behind every record deleted since. The
416
+ * chunk marks go with them: a mark whose rows are gone is exactly what makes
417
+ * a resumed load skip a chunk and finish incomplete.
418
+ *
419
+ * What survives is what this client owes the server. Unacknowledged writes
420
+ * are replayed onto the epoch it lands on, and the acked high-water mark
421
+ * keeps their sequences from colliding with fresh ones — dropping either
422
+ * would lose writes the app has already been told were saved.
423
+ */
424
+ discardMergedView(): void;
425
+ private rows;
426
+ private scalar;
427
+ }
428
+
78
429
  interface ITransactionalDatabaseOperations {
79
430
  insert(modelName: string, data: any): Promise<void>;
80
431
  delete(modelName: string, id: string): Promise<void>;
@@ -130,6 +481,32 @@ declare abstract class DatabaseEngine {
130
481
  */
131
482
  deleteByDocumentId(_modelName: string, _docId: string): Promise<void>;
132
483
  getTableName(_modelName: string): string;
484
+ /**
485
+ * A SYNCHRONOUS SQL executor over this engine's database, used by the
486
+ * large-document (format 2) record store. That store commits a merged
487
+ * record row and its pending-operation entry as one transaction — the
488
+ * barrier a `save()` resolves behind — which the async `query()` API cannot
489
+ * express. Engines whose underlying SQLite is synchronous (better-sqlite3,
490
+ * SQL.js) provide it; anything else leaves it undefined and cannot host a
491
+ * large document.
492
+ */
493
+ createFormat2Sql?: () => Format2StoreSql;
494
+ /**
495
+ * Whether what {@link createFormat2Sql} writes survives this process.
496
+ *
497
+ * On a large document the merged record view IS the document: the Y.Doc
498
+ * holds one epoch's overlay, and everything else — including a record a
499
+ * `save()` has already resolved, and the pending write that still owes it to
500
+ * the server — lives only in this store. An engine whose database is in
501
+ * memory therefore cannot host one: a reload does not cost a cache, it costs
502
+ * the document and the writes nobody has acknowledged yet.
503
+ *
504
+ * The platform is asked separately about its storage quota; this answers the
505
+ * half only the engine knows. An engine that does not implement it is taken
506
+ * at the platform's word, which is what keeps hosts with storage of their
507
+ * own — a worker handed a file store, a test — working unchanged.
508
+ */
509
+ format2StorageIsDurable?: () => boolean;
133
510
  withTransaction<T>(_callback: (operations: ITransactionalDatabaseOperations) => Promise<T>): Promise<T>;
134
511
  }
135
512
 
@@ -321,6 +698,176 @@ declare class DocumentQueryTranslator {
321
698
  private buildDocumentClause;
322
699
  }
323
700
 
701
+ interface RegisteredModelInfo {
702
+ class: typeof BaseModel;
703
+ options: ModelOptions;
704
+ fields: Map<string, FieldOptions>;
705
+ }
706
+ declare class ModelRegistry {
707
+ private static instance;
708
+ private models;
709
+ private modelOptions;
710
+ private activeSessionModels;
711
+ private dbEngine;
712
+ constructor();
713
+ static getInstance(): ModelRegistry;
714
+ /**
715
+ * A registry for ONE `initJsBao` instance (#3039).
716
+ *
717
+ * The global singleton stays the registration target for
718
+ * `attachAndRegisterModel` / `createModelClass`; this is the per-instance
719
+ * view the ORM actually resolves schemas through, so two clients that
720
+ * register different models under the same name no longer overwrite each
721
+ * other's include translation and relationship validation.
722
+ *
723
+ * With explicit `models`, only those classes are in it. With `models`
724
+ * undefined the legacy session semantics are kept exactly: the global
725
+ * registry is SNAPSHOT at init time, so a model registered afterwards is not
726
+ * silently adopted by an already-running instance.
727
+ */
728
+ static createInstanceRegistry(modelClasses?: (typeof BaseModel)[]): ModelRegistry;
729
+ registerModel(modelClass: any, options: ModelOptions, fields: Map<string, FieldOptions>): void;
730
+ getModelClass(name: string): typeof BaseModel | undefined;
731
+ getModelOptions(name: string): ModelOptions | undefined;
732
+ getModelInfo(modelName: string): RegisteredModelInfo | undefined;
733
+ getAllRegisteredModelsInfo(): RegisteredModelInfo[];
734
+ private getModelNameFromClass;
735
+ setExplicitModelsForSession(modelClasses?: (typeof BaseModel)[]): void;
736
+ initializeAll(yDoc: Y.Doc, dbEngine: DatabaseEngine): Promise<void>;
737
+ initializeAllForDocument(yDoc: Y.Doc, dbEngine: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint,
738
+ /** #2816 — `2` prepares the tables without reading the Y.Map as records. */
739
+ documentFormat?: 1 | 2): Promise<void>;
740
+ removeDocumentData(docId: string, dbEngine: DatabaseEngine): Promise<void>;
741
+ initializeRelationships(): Promise<void>;
742
+ private addPrototypeMethod;
743
+ clearSessionState(): void;
744
+ getActiveModels(): Map<string, typeof BaseModel>;
745
+ private validateSessionModels;
746
+ }
747
+
748
+ /**
749
+ * The binding state one ORM instance owns (#3039).
750
+ *
751
+ * Everything here used to live as `BaseModel` statics, so two `initJsBao`
752
+ * instances in one process shared one engine, one set of connected documents,
753
+ * one set of `subscribe()` listeners and one default-document mapping. The
754
+ * static surface survives as accessors that resolve the acting class's
755
+ * context; the state itself lives here, one per instance.
756
+ *
757
+ * Deliberately imports NOTHING at runtime: `BaseModel` reaches for this module
758
+ * during its own evaluation, so a value import of `ModelRegistry` (which
759
+ * imports `BaseModel`) would be a cycle that bites at module-init time.
760
+ */
761
+ declare class OrmContext {
762
+ /** Declared before the field that reads it: static initialization is ordered. */
763
+ private static instanceCounter;
764
+ /**
765
+ * Which instance this is, for diagnostics. A process holding several clients
766
+ * needs a way to say WHICH one a warning is about, so every context takes the
767
+ * next number as it is created and keeps it for its lifetime.
768
+ */
769
+ readonly instanceId: number;
770
+ /** The engine this instance's models read and write through. */
771
+ dbEngine: DatabaseEngine | null;
772
+ connectedDocuments: Map<string, {
773
+ yDoc: Y.Doc;
774
+ permissionHint: DocumentPermissionHint;
775
+ }>;
776
+ /** `docId_modelName` → the Y.Map that document holds for that model. */
777
+ documentYMaps: Map<string, Y.Map<any>>;
778
+ /** `subscribe()` callbacks, by model name. */
779
+ listenersMap: Map<string, Set<() => void>>;
780
+ modelNameToDefaultDocId: Map<string, string>;
781
+ globalDefaultDocId: string | undefined;
782
+ defaultDocChangedListeners: Set<(payload: {
783
+ previous?: string;
784
+ current?: string;
785
+ }) => void>;
786
+ modelDocMappingChangedListeners: Set<(payload: {
787
+ modelName: string;
788
+ previous?: string;
789
+ current?: string;
790
+ }) => void>;
791
+ /** Format-2 ("large document", #2816) record stores by document id. */
792
+ format2Documents: Map<string, Format2RecordStore>;
793
+ /** Format-2 epoch moves in flight (#2816). */
794
+ format2Transitions: Map<string, Promise<void>>;
795
+ /**
796
+ * Nested-Y.Map stringset fields this context has already observed. Per
797
+ * context, not global: a Y.Doc reconnected to a fresh instance has to be
798
+ * observed again by that instance, and a global guard would silently
799
+ * suppress it.
800
+ */
801
+ observedStringSetMaps: WeakSet<Y.Map<any>>;
802
+ /**
803
+ * The registry this instance's schema lookups use. `null` on the standing
804
+ * default context, where callers fall back to the global singleton.
805
+ */
806
+ modelRegistry: ModelRegistry | null;
807
+ /** Model classes routed to this context. */
808
+ boundModels: Set<any>;
809
+ /** True once {@link destroy} has run; bound classes keep pointing here. */
810
+ destroyed: boolean;
811
+ /** Yjs `unobserve` thunks by owning document id. */
812
+ private observerDisposers;
813
+ /** Teardown the owner (the document manager) registers for `destroy()`. */
814
+ private teardownHooks;
815
+ private static readonly NO_DOCUMENT_KEY;
816
+ /**
817
+ * Record how to remove an observer this context installed. Every `observe()`
818
+ * in the ORM pairs with one of these: without it a torn-down instance's
819
+ * callbacks keep firing on a Y.Doc that outlives it.
820
+ */
821
+ registerObserverDisposer(docId: string | null | undefined, dispose: () => void): void;
822
+ /** Remove every observer this context installed for `docId`. */
823
+ disposeObserversForDocument(docId: string): void;
824
+ private disposeAllObservers;
825
+ /** Run `hook` as part of `destroy()` (the format-2 binding registers here). */
826
+ onDestroy(hook: () => void | Promise<void>): void;
827
+ /** The record store for `docId`, or `null` when the document is format 1. */
828
+ getFormat2Store(docId: string | null | undefined): Format2RecordStore | null;
829
+ /**
830
+ * The epoch move a write to `docId` has to wait out (#2816, behavior 10), or
831
+ * `null` when none is in flight.
832
+ */
833
+ format2WritesHeld(docId: string | null | undefined): Promise<void> | null;
834
+ /** Run an epoch move with `docId`'s local writes held back (#2816). */
835
+ holdFormat2Writes<T>(docId: string, run: () => Promise<T>): Promise<T>;
836
+ /** Notify `subscribe()` callbacks registered on this context for `modelName`. */
837
+ notifyListeners(modelName: string, log?: (message: string) => void, onError?: (error: unknown) => void): void;
838
+ setModelDefaultDocumentId(modelName: string, docId: string): void;
839
+ removeModelDefaultDocumentId(modelName: string): void;
840
+ clearModelDefaultDocumentIds(): void;
841
+ setGlobalDefaultDocumentId(docId: string): void;
842
+ clearGlobalDefaultDocumentId(): void;
843
+ getModelDefaultDocumentMapping(): Record<string, string>;
844
+ getDocumentIdForModel(modelName: string): string | undefined;
845
+ getGlobalDefaultDocumentId(): string | undefined;
846
+ onDefaultDocChanged(listener: (payload: {
847
+ previous?: string;
848
+ current?: string;
849
+ }) => void): () => void;
850
+ onModelDocMappingChanged(listener: (payload: {
851
+ modelName: string;
852
+ previous?: string;
853
+ current?: string;
854
+ }) => void): () => void;
855
+ /** Drop the model→document defaults that point at a disconnected document. */
856
+ clearMappingsForDocId(docId: string): void;
857
+ private announceDefaultDoc;
858
+ private announceModelDocMapping;
859
+ /**
860
+ * Tear this instance down: observers off, engine destroyed, state cleared.
861
+ *
862
+ * Deliberately does NOT unbind its model classes. They keep resolving this
863
+ * context, whose engine is now null, so an operation through them throws the
864
+ * ordinary not-initialized error instead of silently landing on whatever
865
+ * other instance happens to be live (#3039, behavior 7). Only a later
866
+ * `initJsBao` may rebind a class whose context is destroyed.
867
+ */
868
+ destroy(): Promise<void>;
869
+ }
870
+
324
871
  interface StringSetMembership$1 {
325
872
  field: string;
326
873
  contains: string;
@@ -365,19 +912,59 @@ declare enum LogLevel {
365
912
  }
366
913
  declare class BaseModel implements StringSetChangeTracker {
367
914
  static modelName?: string;
368
- private static listenersMap;
369
- private static modelNameToDefaultDocId;
370
- private static globalDefaultDocId;
371
- private static defaultDocChangedListeners;
372
- private static modelDocMappingChangedListeners;
915
+ /**
916
+ * The ORM instance this class is bound to (#3039), set by `initJsBao`.
917
+ *
918
+ * Read through the static prototype chain by {@link resolveOrmContext}, so a
919
+ * subclass inherits its parent's binding. Never reassigned by `destroy()`:
920
+ * a destroyed instance's classes keep pointing at their tombstone rather
921
+ * than falling back to a surviving instance.
922
+ */
923
+ static _ormContext?: OrmContext;
924
+ /**
925
+ * Every field below is per-ORM-instance state (#3039). The accessors keep
926
+ * the static surface consumers (and this file's own call sites) already use
927
+ * while the state itself lives on the acting class's {@link OrmContext}.
928
+ */
929
+ protected static get listenersMap(): Map<string, Set<() => void>>;
930
+ protected static set listenersMap(value: Map<string, Set<() => void>>);
931
+ protected static get modelNameToDefaultDocId(): Map<string, string>;
932
+ protected static set modelNameToDefaultDocId(value: Map<string, string>);
933
+ protected static get globalDefaultDocId(): string | undefined;
934
+ protected static set globalDefaultDocId(value: string | undefined);
935
+ protected static get defaultDocChangedListeners(): Set<(payload: {
936
+ previous?: string;
937
+ current?: string;
938
+ }) => void>;
939
+ protected static get modelDocMappingChangedListeners(): Set<(payload: {
940
+ modelName: string;
941
+ previous?: string;
942
+ current?: string;
943
+ }) => void>;
373
944
  private static readonly DEFAULT_LEGACY_DOC_ID;
374
- protected static dbInstance: DatabaseEngine | null;
375
- protected static connectedDocuments: Map<string, {
945
+ protected static get dbInstance(): DatabaseEngine | null;
946
+ protected static set dbInstance(value: DatabaseEngine | null);
947
+ protected static get connectedDocuments(): Map<string, {
376
948
  yDoc: Y.Doc;
377
949
  permissionHint: DocumentPermissionHint;
378
950
  }>;
379
- protected static documentYMaps: Map<string, Y.Map<any>>;
380
- private static _observedStringSetMaps;
951
+ protected static set connectedDocuments(value: Map<string, {
952
+ yDoc: Y.Doc;
953
+ permissionHint: DocumentPermissionHint;
954
+ }>);
955
+ protected static get documentYMaps(): Map<string, Y.Map<any>>;
956
+ protected static set documentYMaps(value: Map<string, Y.Map<any>>);
957
+ /**
958
+ * Format-2 ("large document", #2816) documents by id, each with the record
959
+ * store that holds its merged view. Presence in this map IS format-2-ness on
960
+ * the client: a document without an entry takes the legacy path unchanged.
961
+ */
962
+ private static get format2Documents();
963
+ /**
964
+ * Large documents whose epoch overlay is being replaced right now (#2816,
965
+ * behavior 10), each with the promise that settles when the move is done.
966
+ */
967
+ protected static get format2Transitions(): Map<string, Promise<void>>;
381
968
  private _localChanges;
382
969
  private _isDirty;
383
970
  private _isLoadingFromYjs;
@@ -394,6 +981,53 @@ declare class BaseModel implements StringSetChangeTracker {
394
981
  type: string;
395
982
  static setLogLevel(level: LogLevel): void;
396
983
  static getLogLevel(): LogLevel;
984
+ /**
985
+ * Mark a connected document as format 2 and bind the record store that holds
986
+ * its merged view. Called by `connectDocument(..., { documentFormat: 2 })`.
987
+ */
988
+ static registerFormat2Document(docId: string, store: Format2RecordStore): void;
989
+ static unregisterFormat2Document(docId: string): void;
990
+ /**
991
+ * Drop every format-2 binding. Called when the whole runtime is reset: the
992
+ * stores run over the engine's SQL executor, so one left behind would answer
993
+ * reads out of a destroyed database.
994
+ */
995
+ static clearFormat2Documents(): void;
996
+ /** The record store for `docId`, or `null` when the document is format 1. */
997
+ static getFormat2Store(docId: string | null | undefined): Format2RecordStore | null;
998
+ static isFormat2Document(docId: string | null | undefined): boolean;
999
+ /**
1000
+ * Run an epoch move with `docId`'s local writes held back (#2816,
1001
+ * behavior 10).
1002
+ *
1003
+ * A move replaces the document's overlay: the sealed Y.Doc is destroyed once
1004
+ * the fresh one is bound. A write that resolved its target document before
1005
+ * the move and lands its overlay entry after would land it in the doomed
1006
+ * doc — and it could never be recovered, because `_pending_ops` records
1007
+ * which fields a write touched, not the values it wrote. A write that starts
1008
+ * during the move therefore waits here and resolves the document again on
1009
+ * the other side. (Concurrent moves are serialized for the same reason.)
1010
+ */
1011
+ static holdFormat2Writes<T>(docId: string, run: () => Promise<T>): Promise<T>;
1012
+ /**
1013
+ * The move a write to `docId` has to wait out, or `null` when none is in
1014
+ * flight — which is every write on every document but the one being rotated.
1015
+ */
1016
+ static format2WritesHeld(docId: string | null | undefined): Promise<void> | null;
1017
+ /**
1018
+ * The record store, Y.Doc and model map a format-2 write should land on,
1019
+ * having waited out any epoch move in flight (#2816, behavior 10).
1020
+ *
1021
+ * Resolves to the callers' own values in the overwhelmingly common case that
1022
+ * no move is running, so an ordinary write pays one map lookup. When a move
1023
+ * IS running, the write waits for it and the freshly bound overlay — and its
1024
+ * rebuilt store — are what it lands on.
1025
+ */
1026
+ static resolveFormat2Target(docId: string, modelName: string, store: Format2RecordStore, yDoc: Y.Doc, modelYMap: Y.Map<any>): Promise<{
1027
+ store: Format2RecordStore;
1028
+ yDoc: Y.Doc;
1029
+ modelYMap: Y.Map<any>;
1030
+ }>;
397
1031
  static setModelDefaultDocumentId(modelName: string, docId: string): void;
398
1032
  static removeModelDefaultDocumentId(modelName: string): void;
399
1033
  static clearModelDefaultDocumentIds(): void;
@@ -443,10 +1077,24 @@ declare class BaseModel implements StringSetChangeTracker {
443
1077
  hasFieldChanged(fieldKey: string): boolean;
444
1078
  markStringSetChange(fieldName: string, operation: "add" | "remove" | "clear", value?: string): void;
445
1079
  private getStringSetCurrentValues;
1080
+ /** The schema default for a field, or `undefined` when it has none. */
1081
+ private schemaDefaultFor;
446
1082
  private getStringSetFromYjs;
447
1083
  private getOrCreateStringSet;
448
1084
  static initialize(_yDoc: Y.Doc, _db: DatabaseEngine): Promise<void>;
449
- static initializeForDocument(yDoc: Y.Doc, db: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint): Promise<void>;
1085
+ /**
1086
+ * Initialize this model for a connected document.
1087
+ *
1088
+ * `documentFormat` (#2816) decides where the records come from. On a legacy
1089
+ * document the Y.Map IS the records, so this loads them into the query
1090
+ * tables and observes the map. On a format-2 document the Y.Map is an epoch
1091
+ * OVERLAY of flat `recordId/field` entries — walking it as records would
1092
+ * read a scalar (or a `null` unset) as a record and mint junk rows, and
1093
+ * observing it would run every overlay change through the legacy
1094
+ * record-shaped path. There the tables are prepared here and hydrated and
1095
+ * observed by `Format2DocumentBinding` from the merged view instead.
1096
+ */
1097
+ static initializeForDocument(yDoc: Y.Doc, db: DatabaseEngine, docId: string, permissionHint: DocumentPermissionHint, documentFormat?: 1 | 2): Promise<void>;
450
1098
  static cleanupDocumentData(docId: string): Promise<void>;
451
1099
  static subscribe(callback: () => void): () => void;
452
1100
  protected static notifyListeners(): void;
@@ -481,9 +1129,88 @@ declare class BaseModel implements StringSetChangeTracker {
481
1129
  protected static _buildKeyFromValues(fields: string[], keyValues: any[], modelName: string, constraintName: string): string | null;
482
1130
  protected _buildUniqueKey(fields: string[], data: Record<string, any>, modelName: string, constraintName: string): string | null;
483
1131
  save(options?: SaveOptions$1): Promise<void>;
1132
+ /**
1133
+ * Save onto a format-2 (large) document (#2816).
1134
+ *
1135
+ * The record is NOT written as a nested `Y.Map`: the epoch doc carries flat
1136
+ * field-level overlay entries, and the merged `records` view is what reads
1137
+ * come from. Existence, `upsertOn` and uniqueness are therefore resolved
1138
+ * against that view, not against the doc, which is what makes a base-only
1139
+ * record patchable instead of silently re-created.
1140
+ *
1141
+ * The merged row and the `_pending_ops` entry commit TOGETHER and BEFORE the
1142
+ * overlay lands in the Y.Doc: that ordering is the commit barrier a resolved
1143
+ * `save()` stands behind, and it keeps a crash in between recoverable (the
1144
+ * durable pending op replays the write) rather than divergent.
1145
+ */
1146
+ private saveFormat2;
1147
+ /**
1148
+ * Mirror one merged-view row into the engine's per-model query tables, so
1149
+ * `query()` / `aggregate()` answer from the same state `find()` does. The
1150
+ * merged view remains the source; this is its derived query projection, and
1151
+ * it is awaited so a resolved save is immediately queryable.
1152
+ */
1153
+ /**
1154
+ * Project one merged-view row into the engine's per-model query tables, so
1155
+ * `query()` / `aggregate()` answer from the same state `find()` does. The
1156
+ * merged view stays the source; this is its derived query projection.
1157
+ *
1158
+ * Called (awaited) by the local write path — which is what makes a resolved
1159
+ * save immediately queryable — and enqueued by the overlay observer for
1160
+ * updates that arrive from the sync layer.
1161
+ */
1162
+ static projectFormat2Record(docId: string, recordId: string,
1163
+ /**
1164
+ * `notify: false` withholds the listener notification, for a caller
1165
+ * projecting many records at once (#2816): hydrating a model would
1166
+ * otherwise tell every subscriber once per record, which on a large
1167
+ * document is a re-render per row of the document.
1168
+ */
1169
+ options?: {
1170
+ notify?: boolean;
1171
+ }): Promise<void>;
1172
+ /**
1173
+ * Remove a PAGE of records from the query tables, without consulting the
1174
+ * merged view (#2816).
1175
+ *
1176
+ * The discard path: the rows are erased BEFORE the merged view is thrown
1177
+ * away, because the view is the only place that still names them. Projecting
1178
+ * would re-insert them — the record is still there at that point.
1179
+ */
1180
+ static deleteFormat2QueryRows(recordIds: readonly string[]): Promise<void>;
1181
+ /**
1182
+ * Project a PAGE of merged-view rows into the query tables (#2816).
1183
+ *
1184
+ * The hydration path: one notification for the page rather than one per
1185
+ * record, because a model being hydrated has no intermediate states worth
1186
+ * telling anyone about — and on a large document the per-record version is a
1187
+ * subscriber callback per row of the document.
1188
+ */
1189
+ static projectFormat2Records(docId: string, recordIds: readonly string[]): Promise<void>;
484
1190
  delete(): Promise<void>;
1191
+ /**
1192
+ * Delete from a format-2 (large) document (#2816): a `_deleted` tombstone in
1193
+ * the overlay plus the merged-view removal, committed with its pending op
1194
+ * before the overlay lands, exactly as `saveFormat2` orders its write.
1195
+ */
1196
+ private deleteFormat2;
485
1197
  protected getCurrentJSState(): Record<string, any>;
486
1198
  protected toJSON(): Record<string, any>;
1199
+ /**
1200
+ * Refuse a read through a class whose ORM instance has been destroyed
1201
+ * (#3039, the tombstone).
1202
+ *
1203
+ * `query()`, `count()` and `aggregate()` already refuse: they read the
1204
+ * engine, which a destroyed instance has nulled. The readers that walk the
1205
+ * connected documents instead — `find`, `findAll`, `findByUnique` — would
1206
+ * walk maps `destroy()` emptied and report absence, which a caller cannot
1207
+ * tell apart from a database that genuinely holds no such record. So they
1208
+ * say so instead.
1209
+ *
1210
+ * Only a DESTROYED context is refused. A class that was never bound still
1211
+ * reads as empty before any `initJsBao`, exactly as it always has.
1212
+ */
1213
+ private static assertInstanceAlive;
487
1214
  static find<T extends BaseModel>(this: new (...args: any[]) => T, id: string): Promise<T | null>;
488
1215
  /**
489
1216
  * Document-style query API - returns paginated results
@@ -602,14 +1329,14 @@ declare class BaseModel implements StringSetChangeTracker {
602
1329
  * member arrives via Y.applyUpdate, or a local per-member set/delete).
603
1330
  * Idempotent — re-calling on the same Y.Map is a no-op.
604
1331
  */
605
- protected static observeStringSetMapOnce(nestedMap: Y.Map<any>): void;
1332
+ protected static observeStringSetMapOnce(nestedMap: Y.Map<any>, docId?: string | null): void;
606
1333
  /**
607
1334
  * Walk the schema's stringset fields on `recordYMap` and observe any nested
608
1335
  * Y.Map values. Called from observer setup and from the parent observer body
609
1336
  * so newly-arrived stringset Y.Maps (local migration or remote create) get
610
1337
  * an observer too.
611
1338
  */
612
- protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any): void;
1339
+ protected static attachStringSetObserversToRecord(recordYMap: Y.Map<any>, schema: any, docId?: string | null): void;
613
1340
  /**
614
1341
  * Sets up deep observation on a nested YMap to sync field-level changes to the database
615
1342
  */