js-bao 0.7.0-alpha.8 → 0.7.0-alpha.9

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/browser.cjs CHANGED
@@ -1314,6 +1314,14 @@ var init_DocumentQueryTranslator = __esm({
1314
1314
  }
1315
1315
  return Array.from(new Set(normalized));
1316
1316
  }
1317
+ /**
1318
+ * The `_meta_doc_id` condition for a document scope, alone: `IN (...)`, or
1319
+ * `1 = 0` for an empty scope, or `null` when there is no scope. For a
1320
+ * statement the translator does not assemble itself (the aggregations).
1321
+ */
1322
+ documentCondition(documents) {
1323
+ return this.buildDocumentClause(documents);
1324
+ }
1317
1325
  buildDocumentClause(documents) {
1318
1326
  const normalized = this.normalizeDocumentIds(documents);
1319
1327
  if (normalized === null) {
@@ -2374,6 +2382,69 @@ var init_overlaySql = __esm({
2374
2382
  }
2375
2383
  });
2376
2384
 
2385
+ // src/models/format2FoldedState.ts
2386
+ function orderedClients(vector) {
2387
+ return Object.keys(vector).sort((a, b) => {
2388
+ const left = Number(a);
2389
+ const right = Number(b);
2390
+ if (left !== right) return left < right ? -1 : 1;
2391
+ return a < b ? -1 : a > b ? 1 : 0;
2392
+ });
2393
+ }
2394
+ function canonicalFoldedState(state) {
2395
+ const vector = {};
2396
+ for (const client of orderedClients(state.vector)) {
2397
+ const clock = Number(state.vector[client]);
2398
+ if (Number.isFinite(clock) && clock >= 0) vector[client] = Math.floor(clock);
2399
+ }
2400
+ const models = Array.from(new Set(state.models.map(String))).sort();
2401
+ return { vector, models };
2402
+ }
2403
+ function encodeFoldedState(state) {
2404
+ const canonical = canonicalFoldedState(state);
2405
+ const clients = orderedClients(canonical.vector).map(
2406
+ (client) => `${JSON.stringify(client)}:${canonical.vector[client]}`
2407
+ );
2408
+ return `{"vector":{${clients.join(",")}},"models":${JSON.stringify(canonical.models)}}`;
2409
+ }
2410
+ function decodeFoldedState(raw) {
2411
+ if (raw === null || raw === void 0) return null;
2412
+ try {
2413
+ const parsed = JSON.parse(String(raw));
2414
+ if (!parsed || typeof parsed !== "object" || typeof parsed.vector !== "object" || parsed.vector === null || !Array.isArray(parsed.models)) {
2415
+ return null;
2416
+ }
2417
+ return canonicalFoldedState({
2418
+ vector: parsed.vector,
2419
+ models: parsed.models
2420
+ });
2421
+ } catch {
2422
+ return null;
2423
+ }
2424
+ }
2425
+ function mergeFoldedStates(held, offered) {
2426
+ if (held === null) return canonicalFoldedState(offered);
2427
+ const vector = { ...held.vector };
2428
+ for (const [client, clock] of Object.entries(offered.vector)) {
2429
+ vector[client] = Math.max(vector[client] ?? 0, Number(clock));
2430
+ }
2431
+ return canonicalFoldedState({
2432
+ vector,
2433
+ models: [...held.models, ...offered.models]
2434
+ });
2435
+ }
2436
+ function vectorIsAtOrAhead(docState, stored) {
2437
+ for (const [client, clock] of Object.entries(stored)) {
2438
+ if ((docState[client] ?? 0) < clock) return false;
2439
+ }
2440
+ return true;
2441
+ }
2442
+ var init_format2FoldedState = __esm({
2443
+ "src/models/format2FoldedState.ts"() {
2444
+ "use strict";
2445
+ }
2446
+ });
2447
+
2377
2448
  // src/models/format2RecordStore.ts
2378
2449
  function format2TableNames(docId) {
2379
2450
  let hex = "";
@@ -2386,6 +2457,9 @@ function format2TableNames(docId) {
2386
2457
  };
2387
2458
  }
2388
2459
  function format2ClientDDL(docId) {
2460
+ return [...format2DocumentDDL(docId), ...FORMAT2_SHARED_DDL];
2461
+ }
2462
+ function format2DocumentDDL(docId) {
2389
2463
  const { records, stringSetIndex } = format2TableNames(docId);
2390
2464
  return [
2391
2465
  `CREATE TABLE IF NOT EXISTS ${records} (
@@ -2409,68 +2483,7 @@ function format2ClientDDL(docId) {
2409
2483
  // `(_type, field, _record_id)` shape left the planner narrowing on `_type`
2410
2484
  // alone — a walk of the model's whole member table per record. The old
2411
2485
  // name is dropped in `init()`, not here, so this list stays creation-only.
2412
- `CREATE INDEX IF NOT EXISTS idx_${stringSetIndex}_trf ON ${stringSetIndex}(_type, _record_id, field)`,
2413
- `CREATE TABLE IF NOT EXISTS _epoch (
2414
- doc_id TEXT PRIMARY KEY,
2415
- epoch INTEGER NOT NULL DEFAULT 0,
2416
- acked_seq INTEGER NOT NULL DEFAULT 0,
2417
- last_sync_at INTEGER,
2418
- window_days INTEGER,
2419
- clock_offset INTEGER,
2420
- hydrated_models TEXT
2421
- )`,
2422
- PENDING_OPS_DDL("_pending_ops"),
2423
- `CREATE TABLE IF NOT EXISTS _client_acks (
2424
- doc_id TEXT NOT NULL,
2425
- client_id TEXT NOT NULL,
2426
- acked_seq INTEGER NOT NULL DEFAULT 0,
2427
- PRIMARY KEY (doc_id, client_id)
2428
- )`,
2429
- // #3432 — keyed on the manifest ORDINAL. `{model}/{n}` stopped being a
2430
- // chunk's identity within its manifest the moment a manifest could reuse
2431
- // a chunk from an earlier build: two entries can then share that pair, and
2432
- // a resume keyed on it would treat the second as already loaded.
2433
- `CREATE TABLE IF NOT EXISTS _snapshot_load (
2434
- doc_id TEXT NOT NULL,
2435
- build_id TEXT NOT NULL,
2436
- ordinal INTEGER NOT NULL,
2437
- PRIMARY KEY (doc_id, build_id, ordinal)
2438
- )`,
2439
- // #3435 — which base this merged view is on, and whether its load ever
2440
- // FINISHED. The second half is what makes a partial replacement safe: a
2441
- // convergence may keep a chunk by marking it rather than fetching it only
2442
- // when the rows it is keeping are a complete baseline. A client that is
2443
- // cold, or whose last load was interrupted, has no such guarantee and
2444
- // takes the full plan (finding 3435-R04).
2445
- `CREATE TABLE IF NOT EXISTS _snapshot_base (
2446
- doc_id TEXT PRIMARY KEY,
2447
- build_id TEXT NOT NULL,
2448
- epoch INTEGER NOT NULL,
2449
- complete INTEGER NOT NULL DEFAULT 0
2450
- )`,
2451
- // #3435 — every bulk-load epoch this document crossed and has not yet
2452
- // converged past. Durable, because a connected client that followed a
2453
- // flagged seal onto E+1 has an epoch mark that reads perfectly current:
2454
- // if the debt lived only in memory, a restart would clear it and the
2455
- // client would serve rows the server replaced, for good.
2456
- `CREATE TABLE IF NOT EXISTS _discontinuities (
2457
- doc_id TEXT NOT NULL,
2458
- epoch INTEGER NOT NULL,
2459
- PRIMARY KEY (doc_id, epoch)
2460
- )`,
2461
- // The judgement an epoch move DEFERRED and has not made yet. Durable,
2462
- // written in the same commit as the epoch mark, because the mark moves
2463
- // first: without this a restart in between reads as a client that is
2464
- // perfectly current and whose unacknowledged writes are simply missing
2465
- // from the overlay, so the bind puts them back by key presence alone and
2466
- // publishes an offline write recency would have dropped.
2467
- //
2468
- // Swift's columns and meanings (`Format2RecordStore.swift`), keyed by
2469
- // CLIENT as well as document: tabs share this database, sequence spaces
2470
- // are per client, and a note keyed by document alone would be overwritten
2471
- // by the second tab and read by a restarted one against sequences that
2472
- // were never its own.
2473
- DEFERRED_REPLAY_DDL
2486
+ `CREATE INDEX IF NOT EXISTS idx_${stringSetIndex}_trf ON ${stringSetIndex}(_type, _record_id, field)`
2474
2487
  ];
2475
2488
  }
2476
2489
  function toDeferredReplayNote(rows) {
@@ -2496,6 +2509,24 @@ function format2DocumentIdFromTableName(table) {
2496
2509
  }
2497
2510
  return docId;
2498
2511
  }
2512
+ function legacyAdoptedPools(sql, tables) {
2513
+ if (!tables.includes("_legacy_adopted")) return [];
2514
+ return Array.from(
2515
+ sql.exec("SELECT DISTINCT pool FROM _legacy_adopted")
2516
+ ).map((row) => String(row.pool));
2517
+ }
2518
+ function legacyDeliveredAck(sql, docId, clientId, marked) {
2519
+ if (!listTables(sql).includes("_legacy_handover")) return marked;
2520
+ const handover = Array.from(
2521
+ sql.exec(
2522
+ "SELECT handed_seq, acked_seq FROM _legacy_handover WHERE doc_id = ? AND client_id = ?",
2523
+ docId,
2524
+ clientId
2525
+ )
2526
+ )[0];
2527
+ if (!handover) return marked;
2528
+ return marked <= Number(handover.handed_seq) ? Number(handover.acked_seq) : marked;
2529
+ }
2499
2530
  function listTables(sql) {
2500
2531
  return Array.from(
2501
2532
  sql.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
@@ -2559,13 +2590,14 @@ function normalizeBinding(value) {
2559
2590
  if (typeof value === "number") return value;
2560
2591
  return String(value);
2561
2592
  }
2562
- var FORMAT2_LEGACY_CLIENT_ID, PENDING_OPS_DDL, DEFERRED_REPLAY_DDL, Format2ModelNotHydratedError, Format2RecordStore;
2593
+ var FORMAT2_LEGACY_CLIENT_ID, PENDING_OPS_DDL, DEFERRED_REPLAY_DDL, FORMAT2_SHARED_DDL, LEGACY_HANDOVER_DDL, Format2ModelNotHydratedError, Format2RecordStore;
2563
2594
  var init_format2RecordStore = __esm({
2564
2595
  "src/models/format2RecordStore.ts"() {
2565
2596
  "use strict";
2566
2597
  init_overlaySql();
2567
2598
  init_largeDocuments();
2568
2599
  init_format2OfflineWindow();
2600
+ init_format2FoldedState();
2569
2601
  FORMAT2_LEGACY_CLIENT_ID = "legacy";
2570
2602
  PENDING_OPS_DDL = (table) => `CREATE TABLE IF NOT EXISTS ${table} (
2571
2603
  doc_id TEXT NOT NULL,
@@ -2591,6 +2623,94 @@ var init_format2RecordStore = __esm({
2591
2623
  to_epoch INTEGER NOT NULL,
2592
2624
  kind TEXT NOT NULL,
2593
2625
  PRIMARY KEY (doc_id, client_id)
2626
+ )`;
2627
+ FORMAT2_SHARED_DDL = [
2628
+ `CREATE TABLE IF NOT EXISTS _epoch (
2629
+ doc_id TEXT PRIMARY KEY,
2630
+ epoch INTEGER NOT NULL DEFAULT 0,
2631
+ acked_seq INTEGER NOT NULL DEFAULT 0,
2632
+ last_sync_at INTEGER,
2633
+ window_days INTEGER,
2634
+ clock_offset INTEGER,
2635
+ hydrated_models TEXT,
2636
+ folded_state TEXT
2637
+ )`,
2638
+ PENDING_OPS_DDL("_pending_ops"),
2639
+ `CREATE TABLE IF NOT EXISTS _client_acks (
2640
+ doc_id TEXT NOT NULL,
2641
+ client_id TEXT NOT NULL,
2642
+ acked_seq INTEGER NOT NULL DEFAULT 0,
2643
+ PRIMARY KEY (doc_id, client_id)
2644
+ )`,
2645
+ // #3432 — keyed on the manifest ORDINAL. `{model}/{n}` stopped being a
2646
+ // chunk's identity within its manifest the moment a manifest could reuse
2647
+ // a chunk from an earlier build: two entries can then share that pair, and
2648
+ // a resume keyed on it would treat the second as already loaded.
2649
+ `CREATE TABLE IF NOT EXISTS _snapshot_load (
2650
+ doc_id TEXT NOT NULL,
2651
+ build_id TEXT NOT NULL,
2652
+ ordinal INTEGER NOT NULL,
2653
+ PRIMARY KEY (doc_id, build_id, ordinal)
2654
+ )`,
2655
+ // #3435 — which base this merged view is on, and whether its load ever
2656
+ // FINISHED. The second half is what makes a partial replacement safe: a
2657
+ // convergence may keep a chunk by marking it rather than fetching it only
2658
+ // when the rows it is keeping are a complete baseline. A client that is
2659
+ // cold, or whose last load was interrupted, has no such guarantee and
2660
+ // takes the full plan (finding 3435-R04).
2661
+ `CREATE TABLE IF NOT EXISTS _snapshot_base (
2662
+ doc_id TEXT PRIMARY KEY,
2663
+ build_id TEXT NOT NULL,
2664
+ epoch INTEGER NOT NULL,
2665
+ complete INTEGER NOT NULL DEFAULT 0
2666
+ )`,
2667
+ // #3435 — every bulk-load epoch this document crossed and has not yet
2668
+ // converged past. Durable, because a connected client that followed a
2669
+ // flagged seal onto E+1 has an epoch mark that reads perfectly current:
2670
+ // if the debt lived only in memory, a restart would clear it and the
2671
+ // client would serve rows the server replaced, for good.
2672
+ `CREATE TABLE IF NOT EXISTS _discontinuities (
2673
+ doc_id TEXT NOT NULL,
2674
+ epoch INTEGER NOT NULL,
2675
+ PRIMARY KEY (doc_id, epoch)
2676
+ )`,
2677
+ // The judgement an epoch move DEFERRED and has not made yet. Durable,
2678
+ // written in the same commit as the epoch mark, because the mark moves
2679
+ // first: without this a restart in between reads as a client that is
2680
+ // perfectly current and whose unacknowledged writes are simply missing
2681
+ // from the overlay, so the bind puts them back by key presence alone and
2682
+ // publishes an offline write recency would have dropped.
2683
+ //
2684
+ // Swift's columns and meanings (`Format2RecordStore.swift`), keyed by
2685
+ // CLIENT as well as document: tabs share this database, sequence spaces
2686
+ // are per client, and a note keyed by document alone would be overwritten
2687
+ // by the second tab and read by a restarted one against sequences that
2688
+ // were never its own.
2689
+ DEFERRED_REPLAY_DDL,
2690
+ // #3760 — which of a PRE-AMENDMENT pool's unacknowledged writes this store
2691
+ // has already taken over, so none is ever taken twice.
2692
+ //
2693
+ // Keyed by POOL and not by document (finding 3760-R3). A `doc_id`-keyed
2694
+ // ledger would be swept by that document's purge, and the sequence this
2695
+ // run is about is exactly: import a write, crash before the legacy copy is
2696
+ // dropped, the user discards the document, reopen — at which point a
2697
+ // document-keyed ledger is gone, the legacy copy is still there, and a
2698
+ // write the user explicitly discarded comes back. The pool name outlives
2699
+ // every document of it, so this does too, and only the scope store's own
2700
+ // wipe takes it.
2701
+ `CREATE TABLE IF NOT EXISTS _legacy_adopted (
2702
+ pool TEXT NOT NULL,
2703
+ client_id TEXT NOT NULL,
2704
+ seq INTEGER NOT NULL,
2705
+ PRIMARY KEY (pool, client_id, seq)
2706
+ )`
2707
+ ];
2708
+ LEGACY_HANDOVER_DDL = `CREATE TABLE IF NOT EXISTS _legacy_handover (
2709
+ doc_id TEXT NOT NULL,
2710
+ client_id TEXT NOT NULL,
2711
+ handed_seq INTEGER NOT NULL,
2712
+ acked_seq INTEGER NOT NULL,
2713
+ PRIMARY KEY (doc_id, client_id)
2594
2714
  )`;
2595
2715
  Format2ModelNotHydratedError = class extends Error {
2596
2716
  constructor(model, hydrated) {
@@ -2671,6 +2791,9 @@ var init_format2RecordStore = __esm({
2671
2791
  if (columns.length > 0 && !columns.includes("hydrated_models")) {
2672
2792
  this.sql.exec("ALTER TABLE _epoch ADD COLUMN hydrated_models TEXT");
2673
2793
  }
2794
+ if (columns.length > 0 && !columns.includes("folded_state")) {
2795
+ this.sql.exec("ALTER TABLE _epoch ADD COLUMN folded_state TEXT");
2796
+ }
2674
2797
  const pendingColumns = Array.from(
2675
2798
  this.sql.exec("PRAGMA table_info(_pending_ops)")
2676
2799
  ).map((row) => String(row.name));
@@ -2746,11 +2869,18 @@ var init_format2RecordStore = __esm({
2746
2869
  */
2747
2870
  setHydrationScope(models) {
2748
2871
  const scope = models === null ? null : [...models];
2749
- this.sql.exec(
2750
- "UPDATE _epoch SET hydrated_models = ? WHERE doc_id = ?",
2751
- scope === null ? null : JSON.stringify(scope),
2752
- this.docId
2753
- );
2872
+ const encoded = scope === null ? null : JSON.stringify(scope);
2873
+ const current = this.hydrationScope();
2874
+ this.sql.transaction(() => {
2875
+ if (encoded !== (current === null ? null : JSON.stringify(current))) {
2876
+ this.clearFoldedState();
2877
+ }
2878
+ this.sql.exec(
2879
+ "UPDATE _epoch SET hydrated_models = ? WHERE doc_id = ?",
2880
+ encoded,
2881
+ this.docId
2882
+ );
2883
+ });
2754
2884
  this.scope = scope;
2755
2885
  this.scopeLoaded = true;
2756
2886
  }
@@ -3225,6 +3355,7 @@ var init_format2RecordStore = __esm({
3225
3355
  */
3226
3356
  setEpoch(epoch, options = {}) {
3227
3357
  this.sql.transaction(() => {
3358
+ if (epoch !== this.epoch()) this.clearFoldedState();
3228
3359
  this.sql.exec(
3229
3360
  "UPDATE _epoch SET epoch = ? WHERE doc_id = ?",
3230
3361
  epoch,
@@ -3295,6 +3426,59 @@ var init_format2RecordStore = __esm({
3295
3426
  this.client()
3296
3427
  );
3297
3428
  }
3429
+ // ── What the store last folded (#3782) ───────────────────────────
3430
+ /**
3431
+ * The overlay state the merged view was last folded from, or `null` when
3432
+ * nothing is known — a store written before this, or one whose rows were
3433
+ * just replaced from another source.
3434
+ *
3435
+ * What it vouches for: for every model in `models`, every overlay item of
3436
+ * that model's map below `vector` has been folded into `records` AND either
3437
+ * projected into the query tables or had its model's projection mark
3438
+ * withdrawn.
3439
+ */
3440
+ foldedState() {
3441
+ const rows = this.rows(
3442
+ "SELECT folded_state FROM _epoch WHERE doc_id = ?",
3443
+ this.docId
3444
+ );
3445
+ return rows.length > 0 ? decodeFoldedState(rows[0].folded_state) : null;
3446
+ }
3447
+ /**
3448
+ * Record that a fold certified `state`. Called INSIDE the transaction of the
3449
+ * fold it vouches for, never beside it.
3450
+ *
3451
+ * Merged per client by MAX, with the models unioned — but only while the
3452
+ * document that folded, at `docState`, is at or ahead of the stored vector
3453
+ * on every client. Otherwise it answers `behind` and writes nothing: a
3454
+ * lagging document folded older values under keys the stored vector
3455
+ * vouches for newer ones of (finding 3782-R02), and a MAX would go on
3456
+ * certifying the newer state over the older rows.
3457
+ *
3458
+ * A store that knows nothing (`NULL`) can only be stamped by a fold of the
3459
+ * WHOLE overlay: an incremental batch folded its own keys and says nothing
3460
+ * about the rest, so it answers `skipped` there.
3461
+ */
3462
+ noteFoldedState(state, options) {
3463
+ const held = this.foldedState();
3464
+ if (held === null && options.whole !== true) return "skipped";
3465
+ if (held !== null && !vectorIsAtOrAhead(options.docState, held.vector)) {
3466
+ return "behind";
3467
+ }
3468
+ this.sql.exec(
3469
+ "UPDATE _epoch SET folded_state = ? WHERE doc_id = ?",
3470
+ encodeFoldedState(mergeFoldedStates(held, state)),
3471
+ this.docId
3472
+ );
3473
+ return "stamped";
3474
+ }
3475
+ /** Nothing is known about what the merged view was folded from. */
3476
+ clearFoldedState() {
3477
+ this.sql.exec(
3478
+ "UPDATE _epoch SET folded_state = NULL WHERE doc_id = ?",
3479
+ this.docId
3480
+ );
3481
+ }
3298
3482
  lastSyncAt() {
3299
3483
  const rows = this.rows(
3300
3484
  "SELECT last_sync_at FROM _epoch WHERE doc_id = ?",
@@ -3477,6 +3661,7 @@ var init_format2RecordStore = __esm({
3477
3661
  let applied = 0;
3478
3662
  this.sql.transaction(() => {
3479
3663
  if (this.chunkIsComplete(buildId, ordinal)) return;
3664
+ this.clearFoldedState();
3480
3665
  for (const entry of entries) {
3481
3666
  if (!this.isHydrated(modelName)) break;
3482
3667
  projectOverlayEntry(this.sql, modelName, entry, this.tables);
@@ -3621,6 +3806,7 @@ var init_format2RecordStore = __esm({
3621
3806
  if (!this.isHydrated(modelName)) {
3622
3807
  return;
3623
3808
  }
3809
+ this.clearFoldedState();
3624
3810
  removed = this.deleteCoverage(modelName, coverage);
3625
3811
  for (const entry of entries) {
3626
3812
  projectOverlayEntry(this.sql, modelName, entry, this.tables);
@@ -3646,6 +3832,7 @@ var init_format2RecordStore = __esm({
3646
3832
  let removed = 0;
3647
3833
  this.sql.transaction(() => {
3648
3834
  removed = this.deleteCoverage(modelName, { after, through }, limit);
3835
+ if (removed > 0) this.clearFoldedState();
3649
3836
  });
3650
3837
  return { removed, done: removed < limit };
3651
3838
  }
@@ -3774,7 +3961,8 @@ var init_format2RecordStore = __esm({
3774
3961
  sql.exec(`DROP TABLE IF EXISTS ${quote(own.stringSetIndex)}`);
3775
3962
  return {
3776
3963
  pendingOps,
3777
- remainingDocuments: countStoredDocuments(sql).length
3964
+ remainingDocuments: countStoredDocuments(sql).length,
3965
+ legacyPools: legacyAdoptedPools(sql, tables)
3778
3966
  };
3779
3967
  });
3780
3968
  }
@@ -3790,6 +3978,29 @@ var init_format2RecordStore = __esm({
3790
3978
  static storedDocuments(sql) {
3791
3979
  return countStoredDocuments(sql);
3792
3980
  }
3981
+ /**
3982
+ * The pre-amendment pools this database's `_legacy_adopted` ledger names
3983
+ * (#3760): while one of them is still on disk, this database is the only
3984
+ * record of which of its writes have already been taken over, and must not
3985
+ * be removed (findings 3760-REVIEW-02 and 3760-REVIEW-09).
3986
+ */
3987
+ static legacyAdoptedPools(sql) {
3988
+ return legacyAdoptedPools(sql, listTables(sql));
3989
+ }
3990
+ /**
3991
+ * Create the bookkeeping every document of this database shares, without
3992
+ * naming one (#3760).
3993
+ *
3994
+ * What a store hosting SEVERAL documents needs before it knows any of them:
3995
+ * the browser's worker opens one database per user scope and must answer
3996
+ * `storedEpoch` and `listDocuments` for it straight away, and a document's
3997
+ * own tables arrive later, on the first `serve` or probe that names it.
3998
+ * Exactly the statements {@link init} runs that are not per document, so
3999
+ * `init` on any document afterwards is a no-op over them.
4000
+ */
4001
+ static ensureSharedSchema(sql) {
4002
+ for (const ddl of FORMAT2_SHARED_DDL) sql.exec(ddl);
4003
+ }
3793
4004
  /**
3794
4005
  * Purge every document this database holds except `keep` (#3754).
3795
4006
  *
@@ -3841,9 +4052,314 @@ var init_format2RecordStore = __esm({
3841
4052
  this.sql.exec(`DELETE FROM ${this.tables.records}`);
3842
4053
  this.sql.exec(`DELETE FROM ${this.tables.stringSetIndex}`);
3843
4054
  this.sql.exec("DELETE FROM _snapshot_load WHERE doc_id = ?", this.docId);
4055
+ this.clearFoldedState();
3844
4056
  this.sql.exec("DELETE FROM _snapshot_base WHERE doc_id = ?", this.docId);
3845
4057
  });
3846
4058
  }
4059
+ // ── a pre-amendment pool's debt (#3760) ───────────────────────────
4060
+ /**
4061
+ * What a pre-amendment per-document pool still owes the server for `docId`.
4062
+ *
4063
+ * Read from the LEGACY database, by a transient worker on the legacy
4064
+ * namespace. Its schema is MIGRATED first, through the one path that knows
4065
+ * how (finding 3760-REVIEW-04): the transient worker's open runs only
4066
+ * {@link ensureSharedSchema}, which creates a missing table and leaves an
4067
+ * existing one exactly as it found it — so against a genuine 0.7.0 pool,
4068
+ * whose `_pending_ops` has no `client_id`, no `mutation` and no
4069
+ * `prior_overlay`, the read below failed on the column and the export
4070
+ * answered "this pool owes nothing". That is the one outcome the intent's
4071
+ * 0.7.0 rule does not permit, and it was silent. {@link init} is what turns
4072
+ * a pool of any 0.7.x or 0.9.x shape into one these columns exist on, and it
4073
+ * maps the 0.7.0 log to {@link FORMAT2_LEGACY_CLIENT_ID} with the acked mark
4074
+ * that measures it (edge E5), which is also what lets edge E14's rule reach
4075
+ * those rows. Writing to a pool this call is about to delete rows from
4076
+ * anyway costs nothing, and is what an 0.8.0 client would have done to it.
4077
+ *
4078
+ * A table that is genuinely ABSENT afterwards is a release that never wrote
4079
+ * it and owes nothing through it (edge E4). Any other read failure is raised:
4080
+ * the adoption logs it and retries at the next open, which is honest, where
4081
+ * reading it as "owes nothing" would silently strand the writes and then
4082
+ * drop nothing — the caller only deletes what it was told moved.
4083
+ */
4084
+ static exportLegacyDocument(sql, docId) {
4085
+ new _Format2RecordStore(sql, docId).init();
4086
+ const tables = new Set(listTables(sql));
4087
+ const rowsOf = (table, query, ...bindings) => {
4088
+ if (!tables.has(table)) return [];
4089
+ return Array.from(sql.exec(query, ...bindings));
4090
+ };
4091
+ const epochRow = rowsOf(
4092
+ "_epoch",
4093
+ "SELECT epoch, acked_seq FROM _epoch WHERE doc_id = ?",
4094
+ docId
4095
+ )[0];
4096
+ return {
4097
+ // A pool with no `_epoch` row for the document was never bound; its
4098
+ // writes, if any, were made on no epoch this can name, and 0 is the
4099
+ // honest answer — the judgement then spans the whole chain.
4100
+ epoch: epochRow ? Number(epochRow.epoch) : 0,
4101
+ pendingOps: rowsOf(
4102
+ "_pending_ops",
4103
+ `SELECT client_id, seq, model, record_id, op, fields, base_epoch, ts,
4104
+ mutation, prior_overlay
4105
+ FROM _pending_ops WHERE doc_id = ? ORDER BY client_id, seq`,
4106
+ docId
4107
+ ),
4108
+ // The mark a server DELIVERY set, not the floor an earlier drop raised
4109
+ // so the pre-amendment allocator could not reuse a handed-over sequence
4110
+ // (finding 3760-REVIEW-08). The importer reads these as "already
4111
+ // acknowledged", and an adopted row at or below that is pruned as sent.
4112
+ clientAcks: rowsOf(
4113
+ "_client_acks",
4114
+ "SELECT client_id, acked_seq FROM _client_acks WHERE doc_id = ?",
4115
+ docId
4116
+ ).map((row) => ({
4117
+ client_id: String(row.client_id),
4118
+ acked_seq: legacyDeliveredAck(
4119
+ sql,
4120
+ docId,
4121
+ String(row.client_id),
4122
+ Number(row.acked_seq)
4123
+ )
4124
+ })),
4125
+ deferredReplay: rowsOf(
4126
+ "_deferred_replay",
4127
+ `SELECT client_id, through_seq, from_epoch, to_epoch, kind
4128
+ FROM _deferred_replay WHERE doc_id = ?`,
4129
+ docId
4130
+ ),
4131
+ discontinuities: rowsOf(
4132
+ "_discontinuities",
4133
+ "SELECT epoch FROM _discontinuities WHERE doc_id = ? ORDER BY epoch",
4134
+ docId
4135
+ ).map((row) => Number(row.epoch))
4136
+ };
4137
+ }
4138
+ /**
4139
+ * Take over `legacy`'s debt for `docId`, once, in ONE transaction (#3760).
4140
+ *
4141
+ * Every row goes in under its ORIGINAL client id. That is what makes the
4142
+ * bind's own adoption do the rest of the work: those clients are dead by
4143
+ * construction — their lock names are keyed on the SCOPE namespace and the
4144
+ * document, which no pre-amendment tab ever held — so
4145
+ * `adoptOrphanedPendingOps` re-sequences them into this tab's space and
4146
+ * classifies each against the merged row, exactly as it does for a sibling
4147
+ * tab that closed (#3431).
4148
+ *
4149
+ * `_legacy_adopted` is consulted first and written in the same transaction,
4150
+ * so a row already taken over is never taken again — including after a crash
4151
+ * that left the legacy copy in place, and including after the document has
4152
+ * been purged in between.
4153
+ *
4154
+ * Answers both halves the caller needs: the rows it took over just now, and
4155
+ * every row of the batch the ledger records — which is what may be deleted
4156
+ * from the legacy pool. They differ after a crash between an import and its
4157
+ * drop: the row is already adopted, so it is not imported again, and its
4158
+ * legacy copy still has to go. Dropping the whole document's log instead of
4159
+ * the named batch would take a row a legacy tab wrote since the export.
4160
+ */
4161
+ static importLegacyDocument(sql, options) {
4162
+ const { docId, pool, legacyEpoch, rows } = options;
4163
+ const imported = [];
4164
+ const settled = [];
4165
+ const discontinuities = [];
4166
+ let note = false;
4167
+ sql.transaction(() => {
4168
+ const adopted = new Set(
4169
+ Array.from(
4170
+ sql.exec(
4171
+ "SELECT client_id, seq FROM _legacy_adopted WHERE pool = ?",
4172
+ pool
4173
+ )
4174
+ // NUL as the separator, and written as an escape: a client id is
4175
+ // whatever the app's build minted and nothing forbids a space in
4176
+ // one, so a printable separator could make two rows one key.
4177
+ ).map((row) => `${row.client_id}\0${row.seq}`)
4178
+ );
4179
+ const throughSeq = /* @__PURE__ */ new Map();
4180
+ const fromEpoch = /* @__PURE__ */ new Map();
4181
+ const lowerTo = (clientId, epoch) => {
4182
+ if (!Number.isFinite(epoch) || epoch <= 0) return;
4183
+ const held = fromEpoch.get(clientId);
4184
+ fromEpoch.set(clientId, held === void 0 ? epoch : Math.min(held, epoch));
4185
+ };
4186
+ const legacyNotes = /* @__PURE__ */ new Map();
4187
+ for (const row of rows.deferredReplay) {
4188
+ const from = Number(row.from_epoch);
4189
+ if (Number.isFinite(from) && from > 0) {
4190
+ legacyNotes.set(String(row.client_id), from);
4191
+ }
4192
+ }
4193
+ for (const row of rows.pendingOps) {
4194
+ const clientId = String(row.client_id);
4195
+ const seq = Number(row.seq);
4196
+ if (adopted.has(`${clientId}\0${seq}`)) {
4197
+ settled.push({ clientId, seq });
4198
+ continue;
4199
+ }
4200
+ sql.exec(
4201
+ `INSERT OR IGNORE INTO _pending_ops
4202
+ (doc_id, client_id, seq, model, record_id, op, fields, base_epoch, ts,
4203
+ mutation, prior_overlay)
4204
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4205
+ docId,
4206
+ clientId,
4207
+ seq,
4208
+ String(row.model),
4209
+ String(row.record_id),
4210
+ String(row.op),
4211
+ String(row.fields),
4212
+ Number(row.base_epoch),
4213
+ Number(row.ts),
4214
+ row.mutation ?? null,
4215
+ row.prior_overlay ?? null
4216
+ );
4217
+ sql.exec(
4218
+ "INSERT OR IGNORE INTO _legacy_adopted (pool, client_id, seq) VALUES (?, ?, ?)",
4219
+ pool,
4220
+ clientId,
4221
+ seq
4222
+ );
4223
+ imported.push({ clientId, seq });
4224
+ settled.push({ clientId, seq });
4225
+ throughSeq.set(clientId, Math.max(throughSeq.get(clientId) ?? 0, seq));
4226
+ lowerTo(clientId, Number(row.base_epoch));
4227
+ const deferred = legacyNotes.get(clientId);
4228
+ if (deferred !== void 0) lowerTo(clientId, deferred);
4229
+ }
4230
+ for (const ack of rows.clientAcks) {
4231
+ sql.exec(
4232
+ `INSERT INTO _client_acks (doc_id, client_id, acked_seq)
4233
+ VALUES (?, ?, ?)
4234
+ ON CONFLICT(doc_id, client_id) DO UPDATE SET
4235
+ acked_seq = MAX(acked_seq, ?)`,
4236
+ docId,
4237
+ String(ack.client_id),
4238
+ Number(ack.acked_seq),
4239
+ Number(ack.acked_seq)
4240
+ );
4241
+ }
4242
+ for (const epoch of rows.discontinuities) {
4243
+ sql.exec(
4244
+ "INSERT OR IGNORE INTO _discontinuities (doc_id, epoch) VALUES (?, ?)",
4245
+ docId,
4246
+ epoch
4247
+ );
4248
+ discontinuities.push(epoch);
4249
+ }
4250
+ for (const [clientId, seq] of throughSeq) {
4251
+ const from = Math.min(fromEpoch.get(clientId) ?? legacyEpoch, legacyEpoch);
4252
+ sql.exec(
4253
+ `INSERT INTO _deferred_replay
4254
+ (doc_id, client_id, through_seq, from_epoch, to_epoch, kind)
4255
+ VALUES (?, ?, ?, ?, ?, 'legacy')
4256
+ ON CONFLICT(doc_id, client_id) DO UPDATE SET
4257
+ through_seq = MAX(through_seq, ?),
4258
+ from_epoch = MIN(from_epoch, ?),
4259
+ to_epoch = MAX(to_epoch, ?),
4260
+ kind = 'legacy'`,
4261
+ docId,
4262
+ clientId,
4263
+ seq,
4264
+ from,
4265
+ legacyEpoch,
4266
+ seq,
4267
+ from,
4268
+ legacyEpoch
4269
+ );
4270
+ note = true;
4271
+ }
4272
+ });
4273
+ return { imported, settled, discontinuities, note };
4274
+ }
4275
+ /**
4276
+ * Delete exactly the rows that were imported, from the LEGACY pool (#3760).
4277
+ *
4278
+ * By primary key, and only the named batch: a legacy tab still running may
4279
+ * have written more since the export, and a blanket delete of the document's
4280
+ * log would take a write nothing has taken over.
4281
+ *
4282
+ * `discontinuities` are the bulk-load debts the scope store has recorded
4283
+ * (finding 3760-REVIEW-06). They are deleted for the same reason the pending
4284
+ * rows are: the debt now lives in the scope store, where a convergence
4285
+ * settles it, and a copy left in a pool that is never removed would keep
4286
+ * putting it back.
4287
+ *
4288
+ * The sequences go with the rows, never back into circulation (finding
4289
+ * 3760-REVIEW-08). A pre-amendment bundle allocates `max(pending, acked) + 1`
4290
+ * per client, so with its rows deleted and nothing acknowledged a legacy tab
4291
+ * that reopens the document under the same client id would hand out
4292
+ * sequence 1 again — and the next import would find `(pool, client, 1)` in
4293
+ * the ledger, read the NEW write as the one already taken over, and delete
4294
+ * it. So the client's `_client_acks` mark is raised to the highest sequence
4295
+ * handed over, the one floor that allocator reads. That mark is a HAND-OVER,
4296
+ * not a delivery, and the scope store must never read it as one (an adopted
4297
+ * row at or below an acknowledged mark is pruned as delivered): the real
4298
+ * acknowledgement is kept beside it in `_legacy_handover`, and
4299
+ * {@link exportLegacyDocument} reports that one.
4300
+ */
4301
+ static dropLegacyDocument(sql, docId, rows, discontinuities = []) {
4302
+ if (rows.length === 0 && discontinuities.length === 0) return 0;
4303
+ let dropped = 0;
4304
+ const handedThrough = /* @__PURE__ */ new Map();
4305
+ for (const row of rows) {
4306
+ handedThrough.set(
4307
+ row.clientId,
4308
+ Math.max(handedThrough.get(row.clientId) ?? 0, row.seq)
4309
+ );
4310
+ }
4311
+ sql.transaction(() => {
4312
+ if (handedThrough.size > 0) sql.exec(LEGACY_HANDOVER_DDL);
4313
+ for (const [clientId, through] of handedThrough) {
4314
+ const marked = Number(
4315
+ Array.from(
4316
+ sql.exec(
4317
+ "SELECT acked_seq FROM _client_acks WHERE doc_id = ? AND client_id = ?",
4318
+ docId,
4319
+ clientId
4320
+ )
4321
+ )[0]?.acked_seq ?? 0
4322
+ );
4323
+ const delivered = legacyDeliveredAck(sql, docId, clientId, marked);
4324
+ const floor = Math.max(marked, through);
4325
+ sql.exec(
4326
+ `INSERT INTO _client_acks (doc_id, client_id, acked_seq) VALUES (?, ?, ?)
4327
+ ON CONFLICT (doc_id, client_id) DO UPDATE SET acked_seq = excluded.acked_seq`,
4328
+ docId,
4329
+ clientId,
4330
+ floor
4331
+ );
4332
+ sql.exec(
4333
+ `INSERT INTO _legacy_handover (doc_id, client_id, handed_seq, acked_seq)
4334
+ VALUES (?, ?, ?, ?)
4335
+ ON CONFLICT (doc_id, client_id) DO UPDATE SET
4336
+ handed_seq = excluded.handed_seq,
4337
+ acked_seq = excluded.acked_seq`,
4338
+ docId,
4339
+ clientId,
4340
+ floor,
4341
+ delivered
4342
+ );
4343
+ }
4344
+ for (const epoch of discontinuities) {
4345
+ sql.exec(
4346
+ "DELETE FROM _discontinuities WHERE doc_id = ? AND epoch = ?",
4347
+ docId,
4348
+ epoch
4349
+ );
4350
+ }
4351
+ for (const row of rows) {
4352
+ sql.exec(
4353
+ "DELETE FROM _pending_ops WHERE doc_id = ? AND client_id = ? AND seq = ?",
4354
+ docId,
4355
+ row.clientId,
4356
+ row.seq
4357
+ );
4358
+ dropped++;
4359
+ }
4360
+ });
4361
+ return dropped;
4362
+ }
3847
4363
  // ── helpers ───────────────────────────────────────────────────────
3848
4364
  rows(query, ...bindings) {
3849
4365
  return Array.from(this.sql.exec(query, ...bindings));
@@ -4039,10 +4555,10 @@ function observeFormat2Overlay(doc, store, onApplied) {
4039
4555
  doc.on("afterTransaction", handler);
4040
4556
  return () => doc.off("afterTransaction", handler);
4041
4557
  }
4042
- function foldFormat2Overlay(doc, store, onApplied) {
4558
+ function foldFormat2Overlay(doc, store, onApplied, options = {}) {
4043
4559
  const sink = store;
4044
4560
  if (typeof sink.foldWholeOverlay === "function") {
4045
- sink.foldWholeOverlay(doc, onApplied);
4561
+ sink.foldWholeOverlay(doc, onApplied, options);
4046
4562
  return;
4047
4563
  }
4048
4564
  const sync = store;
@@ -4066,6 +4582,68 @@ var init_format2Observer = __esm({
4066
4582
  }
4067
4583
  });
4068
4584
 
4585
+ // src/models/format2OverlayKeysSince.ts
4586
+ function readStateVector(doc) {
4587
+ const vector = {};
4588
+ for (const client of doc.store.clients.keys()) {
4589
+ vector[String(client)] = Y7.getState(doc.store, client);
4590
+ }
4591
+ return vector;
4592
+ }
4593
+ function foldableModels(doc, isHydrated = () => true) {
4594
+ const models = [];
4595
+ for (const name of doc.share.keys()) {
4596
+ if (!isFormat2ModelMap(name)) continue;
4597
+ if (!isHydrated(name)) continue;
4598
+ models.push(name);
4599
+ }
4600
+ return models.sort();
4601
+ }
4602
+ function overlayKeysSince(doc, stored, isHydrated = () => true) {
4603
+ const rootNames = /* @__PURE__ */ new Map();
4604
+ for (const [name, type] of doc.share) rootNames.set(type, name);
4605
+ const covered = new Set(stored.models);
4606
+ const keys = /* @__PURE__ */ new Map();
4607
+ let moved = false;
4608
+ let visited = 0;
4609
+ for (const [client, structs] of doc.store.clients) {
4610
+ const from = stored.vector[String(client)] ?? 0;
4611
+ if (Y7.getState(doc.store, client) <= from) continue;
4612
+ moved = true;
4613
+ let index = from === 0 ? 0 : Y7.findIndexSS(structs, from);
4614
+ for (; index < structs.length; index++) {
4615
+ const struct = structs[index];
4616
+ visited++;
4617
+ if (!(struct instanceof Y7.Item)) continue;
4618
+ const key = struct.parentSub;
4619
+ if (typeof key !== "string") continue;
4620
+ const model = rootNames.get(struct.parent);
4621
+ if (model === void 0 || !isFormat2ModelMap(model)) continue;
4622
+ if (!isHydrated(model) || !covered.has(model)) continue;
4623
+ let set = keys.get(model);
4624
+ if (!set) keys.set(model, set = /* @__PURE__ */ new Set());
4625
+ set.add(key);
4626
+ }
4627
+ }
4628
+ const uncovered = [];
4629
+ for (const model of foldableModels(doc, isHydrated)) {
4630
+ if (covered.has(model)) continue;
4631
+ const map = doc.getMap(model);
4632
+ if (map.size === 0) continue;
4633
+ uncovered.push(model);
4634
+ }
4635
+ if (uncovered.length > 0) moved = true;
4636
+ return { moved, keys, uncovered, visited };
4637
+ }
4638
+ var Y7;
4639
+ var init_format2OverlayKeysSince = __esm({
4640
+ "src/models/format2OverlayKeysSince.ts"() {
4641
+ "use strict";
4642
+ Y7 = __toESM(require("yjs"), 1);
4643
+ init_format2Observer();
4644
+ }
4645
+ });
4646
+
4069
4647
  // src/models/format2RecordStoreAsync.ts
4070
4648
  function widenDeferredReplay(held, note, epoch) {
4071
4649
  if (!note) {
@@ -4082,7 +4660,7 @@ function widenDeferredReplay(held, note, epoch) {
4082
4660
  function isFormat2RecordStoreAsync(value) {
4083
4661
  return value instanceof Format2RecordStoreAsync;
4084
4662
  }
4085
- var ROW_REFRESH_PAGE, Format2FoldBrokenError, Format2RecordStoreAsync;
4663
+ var ROW_REFRESH_PAGE, LOCAL_STAMP_IDLE_MS, FORMAT2_HOLD_ATTEMPTS, FORMAT2_HOLD_RETRY_MS, Format2FoldBrokenError, Format2RecordStoreAsync;
4086
4664
  var init_format2RecordStoreAsync = __esm({
4087
4665
  "src/models/format2RecordStoreAsync.ts"() {
4088
4666
  "use strict";
@@ -4092,8 +4670,12 @@ var init_format2RecordStoreAsync = __esm({
4092
4670
  init_snapshotChunks();
4093
4671
  init_format2RecordStore();
4094
4672
  init_format2RowCache();
4673
+ init_format2OverlayKeysSince();
4095
4674
  init_format2Observer();
4096
4675
  ROW_REFRESH_PAGE = 500;
4676
+ LOCAL_STAMP_IDLE_MS = 250;
4677
+ FORMAT2_HOLD_ATTEMPTS = 5;
4678
+ FORMAT2_HOLD_RETRY_MS = 1e3;
4097
4679
  Format2FoldBrokenError = class extends Error {
4098
4680
  constructor(docId, cause) {
4099
4681
  super(
@@ -4110,6 +4692,12 @@ var init_format2RecordStoreAsync = __esm({
4110
4692
  this.host = host;
4111
4693
  this.docId = docId;
4112
4694
  }
4695
+ /**
4696
+ * How long a held batch waits for its document to catch up before it is
4697
+ * tried again, overridable so a suite need not spend it.
4698
+ * @internal
4699
+ */
4700
+ static holdRetryMs = FORMAT2_HOLD_RETRY_MS;
4113
4701
  cache = new Format2RowCache();
4114
4702
  state = "ready";
4115
4703
  foldBrokenCause = null;
@@ -4119,6 +4707,10 @@ var init_format2RecordStoreAsync = __esm({
4119
4707
  /** Operations admitted and not yet finished, for `drain()`. */
4120
4708
  admitted = 0;
4121
4709
  drainWaiters = [];
4710
+ /** Local writes whose commit or projection is still running (#3756). */
4711
+ localWrites = 0;
4712
+ localWriteWaiters = [];
4713
+ localWritesRefused = false;
4122
4714
  /**
4123
4715
  * Mark commands in flight, outside the scheduler.
4124
4716
  *
@@ -4138,6 +4730,38 @@ var init_format2RecordStoreAsync = __esm({
4138
4730
  unfoldedBatches = 0;
4139
4731
  /** The mirror. Unused on an in-process host, where the store is asked. */
4140
4732
  mirror = null;
4733
+ /**
4734
+ * The document that IS this store's current overlay (#3782). Only its
4735
+ * batches are stamped: a sealed archive folded through a throwaway doc
4736
+ * describes an epoch that is over, and its vector means nothing here.
4737
+ */
4738
+ trackedOverlay = null;
4739
+ /**
4740
+ * The tracked overlay's own clock before the earliest local write still in
4741
+ * flight, or `null` (#3782). A write's items are on the overlay the moment
4742
+ * it publishes, but its query row lands only when its projection settles;
4743
+ * a stamp read in between must not vouch for them, so this client's clock
4744
+ * is capped here in every stamp read while any local write is running.
4745
+ */
4746
+ localFloor = null;
4747
+ /**
4748
+ * A settled local write is not yet certified by any stamp (finding
4749
+ * 3782-R06). Issued as a stamp-only batch after an idle window, at a
4750
+ * disconnect, or carried along by the next remote batch's stamp.
4751
+ */
4752
+ localStampOwed = false;
4753
+ localStampTimer = null;
4754
+ /**
4755
+ * A batch the host refused because its document is behind what the store
4756
+ * certifies (#3782, finding 3782-R02). Nothing of it was written. It goes
4757
+ * back out with the next transaction of its document — which is usually the
4758
+ * one that catches it up — or after `holdRetryMs`, and past
4759
+ * {@link FORMAT2_HOLD_ATTEMPTS} refusals it is folded forced.
4760
+ */
4761
+ heldBatch = null;
4762
+ heldTimer = null;
4763
+ /** Which query tables a batch's records are projected into (#3782). */
4764
+ projectionProvider = null;
4141
4765
  /**
4142
4766
  * Open the store behind `host`: create the schema and load the marks mirror.
4143
4767
  */
@@ -4285,6 +4909,120 @@ var init_format2RecordStoreAsync = __esm({
4285
4909
  await this.batchSettled;
4286
4910
  await this.drainMarks();
4287
4911
  }
4912
+ // ── Local writes, commit AND projection ───────────────────────────
4913
+ /**
4914
+ * Run one local write — its scheduled commit and the query projection that
4915
+ * follows it — as a unit a release can wait for (#3756).
4916
+ *
4917
+ * `drain()` covers the commit only: the projection runs after the scheduled
4918
+ * operation resolves, and a projection that finds its document unregistered
4919
+ * returns silently. A release keeps the projection marks, so it must not
4920
+ * unregister while a write it admitted still has its row to project.
4921
+ */
4922
+ async trackLocalWrite(work) {
4923
+ if (this.localWritesRefused || this.state === "closed") {
4924
+ throw new DocumentClosedError(
4925
+ `[format2] Document '${this.docId}' is being released; the write was not run.`
4926
+ );
4927
+ }
4928
+ if (this.localWrites === 0) this.localFloor = this.ownClock();
4929
+ this.localWrites++;
4930
+ try {
4931
+ return await work();
4932
+ } finally {
4933
+ this.localWrites--;
4934
+ if (this.localWrites === 0) {
4935
+ this.localFloor = null;
4936
+ if (this.localStampOwed) this.scheduleLocalStamp();
4937
+ const waiters = this.localWriteWaiters;
4938
+ this.localWriteWaiters = [];
4939
+ for (const waiter of waiters) waiter();
4940
+ }
4941
+ }
4942
+ }
4943
+ /**
4944
+ * A local write just published to the tracked overlay (#3782, finding
4945
+ * 3782-R06). Called inside the scheduled operation that committed it; costs
4946
+ * a flag, not a vector read — the stamp that certifies it reads the vector
4947
+ * when it is issued, once the write's projection has settled.
4948
+ */
4949
+ noteLocalPublish(doc) {
4950
+ if (doc !== this.trackedOverlay) return;
4951
+ this.localStampOwed = true;
4952
+ }
4953
+ /** The tracked overlay's own client clock right now. */
4954
+ ownClock() {
4955
+ const doc = this.trackedOverlay;
4956
+ if (!doc) return null;
4957
+ return readStateVector(doc)[String(doc.clientID)] ?? 0;
4958
+ }
4959
+ /**
4960
+ * Issue the owed local stamp after an idle window, so a burst of saves
4961
+ * stamps once. Not holding the process open: an idle window that never
4962
+ * fires is a stamp the next open pays for with a fold of those writes.
4963
+ */
4964
+ scheduleLocalStamp() {
4965
+ if (this.localStampTimer) clearTimeout(this.localStampTimer);
4966
+ const timer = setTimeout(() => {
4967
+ this.localStampTimer = null;
4968
+ this.issueLocalStamp();
4969
+ }, LOCAL_STAMP_IDLE_MS);
4970
+ timer.unref?.();
4971
+ this.localStampTimer = timer;
4972
+ }
4973
+ cancelLocalStamp() {
4974
+ this.localStampOwed = false;
4975
+ if (this.localStampTimer) {
4976
+ clearTimeout(this.localStampTimer);
4977
+ this.localStampTimer = null;
4978
+ }
4979
+ }
4980
+ /**
4981
+ * Queue the stamp-only batch that certifies the settled local writes,
4982
+ * through the ordinary FIFO and the ordinary guard: every remote batch
4983
+ * queued before it is folded first, and a document behind the store is
4984
+ * refused like any other.
4985
+ */
4986
+ issueLocalStamp() {
4987
+ if (!this.localStampOwed) return;
4988
+ this.localStampOwed = false;
4989
+ const doc = this.trackedOverlay;
4990
+ if (!doc || this.state === "closed") return;
4991
+ const batch = this.openBatch(doc);
4992
+ batch.stamp = this.captureStamp(doc);
4993
+ this.applyBatchInThread(batch);
4994
+ }
4995
+ /**
4996
+ * Issue the owed local stamp now and wait for it (#3782). A disconnect
4997
+ * calls it once every local write has projected and before the facade
4998
+ * closes, so a clean close leaves the store certified.
4999
+ */
5000
+ async flushLocalStamp() {
5001
+ if (!this.localStampOwed) return;
5002
+ if (this.localStampTimer) {
5003
+ clearTimeout(this.localStampTimer);
5004
+ this.localStampTimer = null;
5005
+ }
5006
+ this.issueLocalStamp();
5007
+ await this.whenBatchesSettled();
5008
+ }
5009
+ /** Local writes admitted and not yet projected. */
5010
+ localWritesInFlight() {
5011
+ return this.localWrites;
5012
+ }
5013
+ /** Resolves when no local write is committing or projecting. */
5014
+ whenLocalWritesSettled() {
5015
+ if (this.localWrites === 0) return Promise.resolve();
5016
+ return new Promise((resolve) => this.localWriteWaiters.push(resolve));
5017
+ }
5018
+ /**
5019
+ * Refuse every local write from now on. The release calls this once the
5020
+ * writes it waited for have landed, in the same turn as its last check, so
5021
+ * no write can be admitted between that check and the unregistering.
5022
+ */
5023
+ refuseLocalWrites() {
5024
+ this.localWritesRefused = true;
5025
+ }
4288
5026
  ops() {
4289
5027
  const dispatch = (command) => this.host.dispatch(command);
4290
5028
  return {
@@ -4397,6 +5135,7 @@ var init_format2RecordStoreAsync = __esm({
4397
5135
  return;
4398
5136
  }
4399
5137
  const marks = this.mirrored();
5138
+ if (marks.epoch !== epoch) marks.foldedState = null;
4400
5139
  marks.epoch = epoch;
4401
5140
  marks.deferredReplay = widenDeferredReplay(
4402
5141
  marks.deferredReplay,
@@ -4492,6 +5231,48 @@ var init_format2RecordStoreAsync = __esm({
4492
5231
  );
4493
5232
  this.refreshMirror({ deferredReplay: null });
4494
5233
  }
5234
+ // ── What the merged view was last folded from (#3782) ────────────
5235
+ /** The stored folded state, or `null` when nothing is known. */
5236
+ foldedState() {
5237
+ const store = this.direct();
5238
+ return store ? store.foldedState() : this.mirrored().foldedState ?? null;
5239
+ }
5240
+ /**
5241
+ * Say which document is this store's live overlay. The binding calls it at
5242
+ * every connect, so an epoch move's fresh overlay replaces the sealed one.
5243
+ */
5244
+ trackOverlay(doc) {
5245
+ if (this.heldBatch && this.heldBatch.doc !== doc) {
5246
+ this.retireHeld("the document was rebound to a new overlay");
5247
+ }
5248
+ this.trackedOverlay = doc;
5249
+ }
5250
+ /**
5251
+ * Say which query tables a fold's records go into. Set by the binding that
5252
+ * owns the model registry; a store nothing set it on — one registered
5253
+ * directly, with no engine — projects nothing, as it never did.
5254
+ */
5255
+ setProjectionProvider(provider) {
5256
+ this.projectionProvider = provider;
5257
+ }
5258
+ /** What folding the tracked overlay up to now would certify. */
5259
+ captureStamp(doc) {
5260
+ if (doc !== this.trackedOverlay) return null;
5261
+ const vector = readStateVector(doc);
5262
+ const own = String(doc.clientID);
5263
+ if (this.localFloor !== null) {
5264
+ if (this.localFloor === 0) delete vector[own];
5265
+ else if (vector[own] !== void 0) {
5266
+ vector[own] = Math.min(vector[own], this.localFloor);
5267
+ }
5268
+ } else if (this.localStampOwed) {
5269
+ this.cancelLocalStamp();
5270
+ }
5271
+ return {
5272
+ vector,
5273
+ models: foldableModels(doc, (model) => this.isHydrated(model))
5274
+ };
5275
+ }
4495
5276
  /** Whether the base this view is on finished loading (#3435). */
4496
5277
  baseComplete() {
4497
5278
  const store = this.direct();
@@ -4511,7 +5292,11 @@ var init_format2RecordStoreAsync = __esm({
4511
5292
  const store = this.direct();
4512
5293
  if (store) store.setHydrationScope(scope);
4513
5294
  else {
4514
- this.mirrored().hydrationScope = scope;
5295
+ const marks = this.mirrored();
5296
+ if (JSON.stringify(marks.hydrationScope) !== JSON.stringify(scope)) {
5297
+ marks.foldedState = null;
5298
+ }
5299
+ marks.hydrationScope = scope;
4515
5300
  this.sendMark({ kind: "setHydrationScope", models: scope });
4516
5301
  }
4517
5302
  }
@@ -4707,6 +5492,7 @@ var init_format2RecordStoreAsync = __esm({
4707
5492
  ordinal
4708
5493
  })
4709
5494
  );
5495
+ this.refreshMirror({ foldedState: null });
4710
5496
  for (const entry of entries) {
4711
5497
  if (this.cache.peek(model, entry.id)) {
4712
5498
  await this.refreshRows(model, [entry.id]);
@@ -4745,6 +5531,7 @@ var init_format2RecordStoreAsync = __esm({
4745
5531
  })
4746
5532
  );
4747
5533
  if (!reply.skipped) {
5534
+ this.refreshMirror({ foldedState: null });
4748
5535
  await this.refreshRange(
4749
5536
  model,
4750
5537
  coverage,
@@ -4760,6 +5547,7 @@ var init_format2RecordStoreAsync = __esm({
4760
5547
  () => this.host.dispatch({ kind: "deleteRange", model, after, through, limit })
4761
5548
  );
4762
5549
  if (reply.removed > 0) {
5550
+ this.refreshMirror({ foldedState: null });
4763
5551
  await this.refreshRange(model, { after, through }, []);
4764
5552
  }
4765
5553
  return reply;
@@ -4838,6 +5626,7 @@ var init_format2RecordStoreAsync = __esm({
4838
5626
  "discardMergedView",
4839
5627
  () => this.host.dispatch({ kind: "discardMergedView" })
4840
5628
  );
5629
+ this.refreshMirror({ foldedState: null });
4841
5630
  this.cache.invalidateAll();
4842
5631
  }
4843
5632
  // ── Presence and adoption (#3431) ─────────────────────────────────
@@ -4913,6 +5702,26 @@ var init_format2RecordStoreAsync = __esm({
4913
5702
  );
4914
5703
  return adopted;
4915
5704
  }
5705
+ // ── The query projection (#3782) ──────────────────────────────────
5706
+ /**
5707
+ * Project the named records of `model` into its query tables, on the host.
5708
+ *
5709
+ * One command however many ids: the host reads each record from its own
5710
+ * `records` and writes the derived rows in one transaction, so nothing per
5711
+ * record crosses the boundary. A failure withdraws the model's projection
5712
+ * mark on the host and rejects here.
5713
+ */
5714
+ async projectRows(model, ids, spec) {
5715
+ this.assertReadable();
5716
+ this.assertHydrated(model);
5717
+ if (ids.length === 0) return { projected: 0, deleted: 0 };
5718
+ return this.host.dispatch({
5719
+ kind: "projectRows",
5720
+ model,
5721
+ ids: [...ids],
5722
+ spec
5723
+ });
5724
+ }
4916
5725
  // ── Projection marks ──────────────────────────────────────────────
4917
5726
  queryProjectionIsCurrent(model) {
4918
5727
  return this.host.dispatch({
@@ -4946,9 +5755,11 @@ var init_format2RecordStoreAsync = __esm({
4946
5755
  * are COALESCED into one: their key sets are unioned, so one
4947
5756
  * `applyRemoteBatch` carries them and each touched record is reported once.
4948
5757
  */
4949
- enqueueOverlayBatch(doc, touched, onApplied) {
5758
+ enqueueOverlayBatch(doc, touched, onApplied, options = {}) {
4950
5759
  if (this.state === "closed") return;
5760
+ if (this.heldBatch && this.heldBatch.doc === doc) this.releaseHeld(false);
4951
5761
  const batch = this.openBatch(doc);
5762
+ if (options.atBind) batch.atBind = true;
4952
5763
  for (const [model, keys] of touched) {
4953
5764
  let set = batch.touched.get(model);
4954
5765
  if (!set) batch.touched.set(model, set = /* @__PURE__ */ new Set());
@@ -4957,16 +5768,23 @@ var init_format2RecordStoreAsync = __esm({
4957
5768
  if (onApplied && !batch.onApplied.includes(onApplied)) {
4958
5769
  batch.onApplied.push(onApplied);
4959
5770
  }
5771
+ batch.stamp = this.captureStamp(doc);
4960
5772
  this.applyBatchInThread(batch);
4961
5773
  }
4962
5774
  /** Queue a WHOLE-overlay catch-up fold (connect, refold, sealed overlay). */
4963
- foldWholeOverlay(doc, onApplied) {
5775
+ foldWholeOverlay(doc, onApplied, options = {}) {
4964
5776
  if (this.state === "closed") return;
4965
5777
  const batch = this.openBatch(doc);
4966
5778
  batch.wholeDocs.push(doc);
5779
+ if (options.atBind) batch.atBind = true;
4967
5780
  if (onApplied && !batch.onApplied.includes(onApplied)) {
4968
5781
  batch.onApplied.push(onApplied);
4969
5782
  }
5783
+ const stamp = this.captureStamp(doc);
5784
+ if (stamp) {
5785
+ batch.stamp = stamp;
5786
+ batch.whole = true;
5787
+ }
4970
5788
  this.applyBatchInThread(batch);
4971
5789
  }
4972
5790
  openBatch(doc) {
@@ -4979,7 +5797,11 @@ var init_format2RecordStoreAsync = __esm({
4979
5797
  touched: /* @__PURE__ */ new Map(),
4980
5798
  wholeDocs: [],
4981
5799
  onApplied: [],
4982
- applied: false
5800
+ applied: false,
5801
+ stamp: null,
5802
+ whole: false,
5803
+ atBind: false,
5804
+ behind: 0
4983
5805
  };
4984
5806
  this.pendingBatch = batch;
4985
5807
  this.unfoldedBatches++;
@@ -5014,6 +5836,169 @@ var init_format2RecordStoreAsync = __esm({
5014
5836
  }
5015
5837
  return entries;
5016
5838
  }
5839
+ /** Batches held because their document lags the store: 0 or 1. */
5840
+ heldBatches() {
5841
+ return this.heldBatch ? 1 : 0;
5842
+ }
5843
+ /** Everything `source` stands for, added to `target`. */
5844
+ mergeBatch(target, source) {
5845
+ for (const [model, keys] of source.touched) {
5846
+ let set = target.touched.get(model);
5847
+ if (!set) target.touched.set(model, set = /* @__PURE__ */ new Set());
5848
+ for (const key of keys) set.add(key);
5849
+ }
5850
+ for (const doc of source.wholeDocs) {
5851
+ if (!target.wholeDocs.includes(doc)) target.wholeDocs.push(doc);
5852
+ }
5853
+ for (const notify of source.onApplied) {
5854
+ if (!target.onApplied.includes(notify)) target.onApplied.push(notify);
5855
+ }
5856
+ target.stamp = target.stamp ?? source.stamp;
5857
+ target.whole = target.whole || source.whole;
5858
+ target.behind = Math.max(target.behind, source.behind);
5859
+ }
5860
+ /** The host refused `batch`: its document is behind the store. Hold it. */
5861
+ holdBatch(batch) {
5862
+ batch.behind++;
5863
+ if (this.state === "closed") return;
5864
+ if (batch.doc !== this.trackedOverlay) {
5865
+ Logger.warn(
5866
+ `[format2] ${this.docId}: a fold refused for an overlay this document has since moved off was dropped; nothing of it was written`
5867
+ );
5868
+ return;
5869
+ }
5870
+ if (this.heldBatch && this.heldBatch !== batch) {
5871
+ if (this.heldBatch.doc === batch.doc) {
5872
+ this.mergeBatch(this.heldBatch, batch);
5873
+ } else {
5874
+ this.retireHeld("the document was rebound to a new overlay");
5875
+ this.heldBatch = batch;
5876
+ }
5877
+ } else {
5878
+ this.heldBatch = batch;
5879
+ }
5880
+ const held = this.heldBatch;
5881
+ Logger.warn(
5882
+ `[format2] ${this.docId}: the store is ahead of this document; its fold is held until the document catches up (refused ${held.behind} of ${FORMAT2_HOLD_ATTEMPTS} time(s) before it is folded forced).`
5883
+ );
5884
+ if (this.heldTimer) clearTimeout(this.heldTimer);
5885
+ const timer = setTimeout(() => {
5886
+ this.heldTimer = null;
5887
+ this.releaseHeld(true);
5888
+ }, _Format2RecordStoreAsync.holdRetryMs);
5889
+ timer.unref?.();
5890
+ this.heldTimer = timer;
5891
+ }
5892
+ /**
5893
+ * Put the held batch back in the queue. `schedule` false leaves it pending
5894
+ * for the caller, who is adding a transaction's keys to it.
5895
+ */
5896
+ releaseHeld(schedule) {
5897
+ const held = this.heldBatch;
5898
+ if (!held) return;
5899
+ if (held.doc !== this.trackedOverlay) {
5900
+ this.retireHeld("its overlay is no longer the document's live one");
5901
+ return;
5902
+ }
5903
+ this.heldBatch = null;
5904
+ if (this.heldTimer) {
5905
+ clearTimeout(this.heldTimer);
5906
+ this.heldTimer = null;
5907
+ }
5908
+ if (this.state === "closed") return;
5909
+ const batch = this.openBatch(held.doc);
5910
+ this.mergeBatch(batch, held);
5911
+ if (schedule) this.applyBatchInThread(batch);
5912
+ }
5913
+ /**
5914
+ * Drop the held batch without folding it (finding 3782-C03). It was refused
5915
+ * because the store is ahead of its document, so it wrote nothing and
5916
+ * certified nothing; what it carried is the sealed overlay's, which the
5917
+ * room archived and the tab the store is ahead of folds.
5918
+ */
5919
+ retireHeld(reason) {
5920
+ const held = this.heldBatch;
5921
+ if (!held) return;
5922
+ this.heldBatch = null;
5923
+ if (this.heldTimer) {
5924
+ clearTimeout(this.heldTimer);
5925
+ this.heldTimer = null;
5926
+ }
5927
+ Logger.warn(
5928
+ `[format2] ${this.docId}: a held fold was dropped because ${reason}; nothing of it was written`
5929
+ );
5930
+ }
5931
+ /**
5932
+ * Fold the held batch with `batch`, which is about to run (finding
5933
+ * 3782-C02). A batch queued while its predecessor's refusal was on the way
5934
+ * opened on its own — nothing was held yet — and its stamp, read after the
5935
+ * refused transaction, covers that transaction's items. Carrying the held
5936
+ * keys is what makes that stamp true; if `batch` is refused in turn, the
5937
+ * two are held together.
5938
+ */
5939
+ absorbHeld(batch) {
5940
+ const held = this.heldBatch;
5941
+ if (!held || held === batch || held.doc !== batch.doc) return;
5942
+ this.heldBatch = null;
5943
+ if (this.heldTimer) {
5944
+ clearTimeout(this.heldTimer);
5945
+ this.heldTimer = null;
5946
+ }
5947
+ this.mergeBatch(batch, held);
5948
+ }
5949
+ /**
5950
+ * The one command a batch is folded, projected and stamped by (#3782).
5951
+ *
5952
+ * `null` when there is nothing to send: no entries and nothing to certify.
5953
+ * A stamp is offered only for the tracked overlay, and only while the
5954
+ * facade is healthy — a batch folded after a failed one must not vouch for
5955
+ * the items the failure left behind — unless the batch folds the WHOLE
5956
+ * overlay, which is the repair. The folding document's CURRENT vector goes
5957
+ * with every tracked-overlay batch, stamped or not, so the host can refuse
5958
+ * a fold from a document the store is ahead of (finding 3782-R02).
5959
+ */
5960
+ batchCommand(batch, entries) {
5961
+ const tracked = batch.doc === this.trackedOverlay;
5962
+ const stamp = tracked && batch.stamp && (batch.whole || this.state === "ready") ? batch.stamp : null;
5963
+ if (entries.length === 0 && stamp === null) return null;
5964
+ const command = {
5965
+ kind: "applyRemoteBatch",
5966
+ entries
5967
+ };
5968
+ const models = Array.from(new Set(entries.map((item) => item.model)));
5969
+ const project = models.length > 0 && this.projectionProvider ? this.projectionProvider(models) : [];
5970
+ if (project.length > 0) command.project = project;
5971
+ if (tracked) command.docState = readStateVector(batch.doc);
5972
+ else command.untracked = true;
5973
+ if (stamp) {
5974
+ command.foldedState = stamp;
5975
+ if (batch.whole) command.whole = true;
5976
+ }
5977
+ if (batch.behind >= FORMAT2_HOLD_ATTEMPTS) {
5978
+ return entries.length === 0 ? null : this.forcedCommand(command);
5979
+ }
5980
+ return command;
5981
+ }
5982
+ /**
5983
+ * The same batch, forced (#3782): folded whatever the store holds, with the
5984
+ * stored folded state cleared instead of written. What a bind does when
5985
+ * its document is behind the store — it cannot wait for the room.
5986
+ */
5987
+ forcedCommand(command) {
5988
+ const { foldedState: _stamp, whole: _whole, ...rest } = command;
5989
+ return { ...rest, force: true };
5990
+ }
5991
+ /** What a folded batch's reply means for this side (#3782). */
5992
+ noteBatchReply(reply) {
5993
+ if (reply.foldedState !== void 0) {
5994
+ this.refreshMirror({ foldedState: reply.foldedState });
5995
+ }
5996
+ for (const failure of reply.projectionFailed ?? []) {
5997
+ Logger.error(
5998
+ `[format2] ${this.docId}: projecting a fold into '${failure.model}' query tables failed; the fold landed, the model's mark is withdrawn and the next bind rebuilds it: ${failure.message}`
5999
+ );
6000
+ }
6001
+ }
5017
6002
  notifyBatch(batch, touched) {
5018
6003
  for (const { model, id } of touched) {
5019
6004
  for (const notify of batch.onApplied) {
@@ -5056,9 +6041,20 @@ var init_format2RecordStoreAsync = __esm({
5056
6041
  this.unfoldedBatches--;
5057
6042
  if (this.pendingBatch === batch) this.pendingBatch = null;
5058
6043
  try {
6044
+ this.absorbHeld(batch);
5059
6045
  const entries = this.materializeBatch(batch);
5060
- if (entries.length === 0) return true;
5061
- const reply = host.dispatchSync({ kind: "applyRemoteBatch", entries });
6046
+ const command = this.batchCommand(batch, entries);
6047
+ if (command === null) return true;
6048
+ let reply = host.dispatchSync(command);
6049
+ this.noteBatchReply(reply);
6050
+ if (reply.behind && batch.atBind) {
6051
+ reply = host.dispatchSync(this.forcedCommand(command));
6052
+ this.noteBatchReply(reply);
6053
+ }
6054
+ if (reply.behind) {
6055
+ this.holdBatch(batch);
6056
+ return true;
6057
+ }
5062
6058
  if (batch.wholeDocs.length > 0) this.clearFoldBroken();
5063
6059
  for (const { model, id } of reply.touched) {
5064
6060
  if (!this.cache.peek(model, id)) continue;
@@ -5078,16 +6074,24 @@ var init_format2RecordStoreAsync = __esm({
5078
6074
  if (this.state === "closed") return;
5079
6075
  try {
5080
6076
  await this.run("applyRemoteBatch", async () => {
6077
+ this.absorbHeld(batch);
5081
6078
  const entries = this.materializeBatch(batch);
5082
6079
  const repairs = batch.wholeDocs.length > 0;
5083
- if (entries.length === 0) {
6080
+ const command = this.batchCommand(batch, entries);
6081
+ if (command === null) {
5084
6082
  if (repairs) this.clearFoldBroken();
5085
6083
  return;
5086
6084
  }
5087
- const reply = await this.host.dispatch({
5088
- kind: "applyRemoteBatch",
5089
- entries
5090
- });
6085
+ let reply = await this.host.dispatch(command);
6086
+ this.noteBatchReply(reply);
6087
+ if (reply.behind && batch.atBind) {
6088
+ reply = await this.host.dispatch(this.forcedCommand(command));
6089
+ this.noteBatchReply(reply);
6090
+ }
6091
+ if (reply.behind) {
6092
+ this.holdBatch(batch);
6093
+ return;
6094
+ }
5091
6095
  if (repairs) this.clearFoldBroken();
5092
6096
  const byModel = /* @__PURE__ */ new Map();
5093
6097
  for (const { model, id } of reply.touched) {
@@ -5128,8 +6132,18 @@ var init_format2RecordStoreAsync = __esm({
5128
6132
  }
5129
6133
  }
5130
6134
  // ── Teardown ──────────────────────────────────────────────────────
5131
- async close(options = {}) {
5132
- if (this.state === "closed") return { wiped: false };
6135
+ /**
6136
+ * Release the facade and its host. Removes nothing (#3756): the query
6137
+ * tables and projection marks stay for the next open.
6138
+ */
6139
+ async close() {
6140
+ if (this.state === "closed") return;
6141
+ this.cancelLocalStamp();
6142
+ this.heldBatch = null;
6143
+ if (this.heldTimer) {
6144
+ clearTimeout(this.heldTimer);
6145
+ this.heldTimer = null;
6146
+ }
5133
6147
  try {
5134
6148
  await this.drain();
5135
6149
  } catch {
@@ -5139,9 +6153,8 @@ var init_format2RecordStoreAsync = __esm({
5139
6153
  } catch {
5140
6154
  }
5141
6155
  this.cache.clear();
5142
- const result = await this.host.close(options);
6156
+ await this.host.close();
5143
6157
  this.state = "closed";
5144
- return result;
5145
6158
  }
5146
6159
  };
5147
6160
  }
@@ -5285,6 +6298,95 @@ CREATE TABLE IF NOT EXISTS _query_projection (
5285
6298
  }
5286
6299
  });
5287
6300
 
6301
+ // src/models/format2RowProjector.ts
6302
+ function quote2(identifier) {
6303
+ return `"${identifier.replace(/"/g, '""')}"`;
6304
+ }
6305
+ function binding(value) {
6306
+ if (value === void 0) return null;
6307
+ if (typeof value === "boolean") return value ? 1 : 0;
6308
+ return value;
6309
+ }
6310
+ function projectFormat2Row(sql, spec, docId, recordId, row) {
6311
+ const table = quote2(spec.table);
6312
+ if (row === null) {
6313
+ sql.exec(`DELETE FROM ${table} WHERE ${quote2("id")} = ?`, recordId);
6314
+ return "deleted";
6315
+ }
6316
+ const stringSetFields = new Set(spec.stringSets.map((set) => set.field));
6317
+ const columns = new Set(spec.columns);
6318
+ const data = {};
6319
+ for (const [key, value] of Object.entries(row)) {
6320
+ if (stringSetFields.has(key)) continue;
6321
+ if (value === void 0) continue;
6322
+ if (!columns.has(key) && key !== "id" && key !== "type") continue;
6323
+ data[key] = value;
6324
+ }
6325
+ data.id = recordId;
6326
+ data.type = spec.model;
6327
+ data._meta_doc_id = docId;
6328
+ data._meta_permission_hint = spec.permissionHint;
6329
+ const names = Object.keys(data);
6330
+ const updates = names.filter((name) => name !== "id").map((name) => `${quote2(name)}=excluded.${quote2(name)}`);
6331
+ sql.exec(
6332
+ `INSERT INTO ${table} (${names.map(quote2).join(", ")}) VALUES (${names.map(() => "?").join(", ")}) ON CONFLICT(${quote2("id")}) DO UPDATE SET ${updates.join(", ")}`,
6333
+ ...names.map((name) => binding(data[name]))
6334
+ );
6335
+ const foreignKey = quote2(spec.foreignKey);
6336
+ for (const { field, table: junctionName } of spec.stringSets) {
6337
+ const junction = quote2(junctionName);
6338
+ const members = (Array.isArray(row[field]) ? row[field] : []).map(String);
6339
+ let current = [];
6340
+ try {
6341
+ current = Array.from(
6342
+ sql.exec(
6343
+ `SELECT ${quote2("value")} FROM ${junction} WHERE ${foreignKey} = ?`,
6344
+ recordId
6345
+ )
6346
+ ).map((entry) => String(entry.value));
6347
+ } catch {
6348
+ current = [];
6349
+ }
6350
+ for (const member of current) {
6351
+ if (members.includes(member)) continue;
6352
+ sql.exec(
6353
+ `DELETE FROM ${junction} WHERE ${foreignKey} = ? AND ${quote2("value")} = ?`,
6354
+ recordId,
6355
+ member
6356
+ );
6357
+ }
6358
+ for (const member of members) {
6359
+ if (current.includes(member)) continue;
6360
+ sql.exec(
6361
+ `INSERT OR IGNORE INTO ${junction} (${quote2("id")}, ${foreignKey}, ${quote2(
6362
+ "value"
6363
+ )}) VALUES (?, ?, ?)`,
6364
+ `${recordId}_${member}`,
6365
+ recordId,
6366
+ member
6367
+ );
6368
+ }
6369
+ }
6370
+ return "projected";
6371
+ }
6372
+ function projectFormat2Rows(sql, spec, docId, ids, readRow) {
6373
+ let projected = 0;
6374
+ let deleted = 0;
6375
+ for (const id of ids) {
6376
+ if (projectFormat2Row(sql, spec, docId, id, readRow(id)) === "projected") {
6377
+ projected++;
6378
+ } else {
6379
+ deleted++;
6380
+ }
6381
+ }
6382
+ return { projected, deleted };
6383
+ }
6384
+ var init_format2RowProjector = __esm({
6385
+ "src/models/format2RowProjector.ts"() {
6386
+ "use strict";
6387
+ }
6388
+ });
6389
+
5288
6390
  // src/models/format2StoreDispatcher.ts
5289
6391
  function readFormat2StoreMarks(store) {
5290
6392
  return {
@@ -5303,7 +6405,9 @@ function readFormat2StoreMarks(store) {
5303
6405
  baseComplete: store.baseState().base?.complete ?? false,
5304
6406
  // #3755 — read on the same round trip as the rest, because the claim path
5305
6407
  // reads it synchronously off the mirror.
5306
- deferredReplay: store.deferredReplay()
6408
+ deferredReplay: store.deferredReplay(),
6409
+ // #3782 — what the bind compares the loaded overlay with.
6410
+ foldedState: store.foldedState()
5307
6411
  };
5308
6412
  }
5309
6413
  function dispatchFormat2StoreCommand(store, sql, command) {
@@ -5368,15 +6472,78 @@ function run(store, sql, command) {
5368
6472
  return { seq, row };
5369
6473
  }
5370
6474
  case "applyRemoteBatch": {
6475
+ if (command.docState !== void 0 && command.force !== true) {
6476
+ const held = store.foldedState();
6477
+ if (held !== null && !vectorIsAtOrAhead(command.docState, held.vector)) {
6478
+ return {
6479
+ touched: [],
6480
+ projected: [],
6481
+ stamped: false,
6482
+ behind: true,
6483
+ foldedState: held
6484
+ };
6485
+ }
6486
+ }
5371
6487
  const touched = [];
6488
+ const projected = [];
6489
+ const projectionFailed = [];
6490
+ let stamped = false;
5372
6491
  sql.transaction(() => {
5373
6492
  for (const { model, entry } of command.entries) {
5374
6493
  if (!store.isHydrated(model)) continue;
5375
6494
  store.applyRemote(model, entry);
5376
6495
  touched.push({ model, id: entry.id });
5377
6496
  }
6497
+ for (const spec of command.project ?? []) {
6498
+ const ids = Array.from(
6499
+ new Set(touched.filter((t) => t.model === spec.model).map((t) => t.id))
6500
+ );
6501
+ if (ids.length === 0) continue;
6502
+ sql.exec("SAVEPOINT f2_project");
6503
+ try {
6504
+ projectFormat2Rows(
6505
+ sql,
6506
+ spec,
6507
+ store.documentId(),
6508
+ ids,
6509
+ (id) => store.read(spec.model, id)
6510
+ );
6511
+ sql.exec("RELEASE f2_project");
6512
+ projected.push({ model: spec.model, count: ids.length });
6513
+ } catch (error) {
6514
+ sql.exec("ROLLBACK TO f2_project");
6515
+ sql.exec("RELEASE f2_project");
6516
+ forgetQueryProjection(sql, store.documentId(), spec.model);
6517
+ projectionFailed.push({
6518
+ model: spec.model,
6519
+ message: error instanceof Error ? error.message : String(error)
6520
+ });
6521
+ }
6522
+ }
6523
+ if (command.force === true) {
6524
+ store.clearFoldedState();
6525
+ Logger.warn(
6526
+ `[format2] ${store.documentId()}: folded from a document behind the store; the next open folds the whole overlay`
6527
+ );
6528
+ } else if (command.foldedState && command.docState) {
6529
+ stamped = store.noteFoldedState(command.foldedState, {
6530
+ docState: command.docState,
6531
+ whole: command.whole === true
6532
+ }) === "stamped";
6533
+ } else if (command.docState === void 0 && command.untracked !== true && touched.length > 0 && store.foldedState() !== null) {
6534
+ store.clearFoldedState();
6535
+ Logger.warn(
6536
+ `[format2] ${store.documentId()}: folded a batch from a tab that predates the folded-state mark; the next open folds the whole overlay`
6537
+ );
6538
+ }
5378
6539
  });
5379
- return { touched };
6540
+ return {
6541
+ touched,
6542
+ projected,
6543
+ ...projectionFailed.length > 0 ? { projectionFailed } : {},
6544
+ stamped,
6545
+ foldedState: store.foldedState()
6546
+ };
5380
6547
  }
5381
6548
  case "applyChunk":
5382
6549
  return {
@@ -5427,6 +6594,23 @@ function run(store, sql, command) {
5427
6594
  forgetQueryProjection(sql, store.documentId());
5428
6595
  return void 0;
5429
6596
  }
6597
+ case "projectRows": {
6598
+ const docId = store.documentId();
6599
+ try {
6600
+ return sql.transaction(
6601
+ () => projectFormat2Rows(
6602
+ sql,
6603
+ command.spec,
6604
+ docId,
6605
+ command.ids,
6606
+ (id) => store.read(command.model, id)
6607
+ )
6608
+ );
6609
+ } catch (error) {
6610
+ forgetQueryProjection(sql, docId, command.model);
6611
+ throw error;
6612
+ }
6613
+ }
5430
6614
  case "purge": {
5431
6615
  const docId = store.documentId();
5432
6616
  return sql.transaction(() => {
@@ -5439,7 +6623,11 @@ function run(store, sql, command) {
5439
6623
  purged: true,
5440
6624
  ...report.nothingStored ? { nothingStored: true } : {},
5441
6625
  pendingOps: report.pendingOps,
5442
- remainingDocuments: report.remainingDocuments
6626
+ remainingDocuments: report.remainingDocuments,
6627
+ // Which pre-amendment pools this store still holds adoption evidence
6628
+ // about: what keeps a last-document eviction from removing the
6629
+ // directory that ledger lives in (finding 3760-REVIEW-02).
6630
+ ...report.legacyPools?.length ? { legacyPools: report.legacyPools } : {}
5443
6631
  };
5444
6632
  });
5445
6633
  }
@@ -5466,8 +6654,11 @@ function run(store, sql, command) {
5466
6654
  var init_format2StoreDispatcher = __esm({
5467
6655
  "src/models/format2StoreDispatcher.ts"() {
5468
6656
  "use strict";
6657
+ init_logger();
5469
6658
  init_format2RecordStore();
5470
6659
  init_format2QueryProjection();
6660
+ init_format2RowProjector();
6661
+ init_format2FoldedState();
5471
6662
  }
5472
6663
  });
5473
6664
 
@@ -5644,32 +6835,13 @@ var init_format2StoreHost = __esm({
5644
6835
  this.log.length = 0;
5645
6836
  }
5646
6837
  /**
5647
- * The only holder, always: an in-process host owns its executor. So a
5648
- * `wipeIfLast` always runs, through the hook the binding injected — which is
5649
- * the same engine delete and mark withdrawal the main thread used to do
5650
- * before the disconnect gate moved (#3431). Node's observable result is
5651
- * unchanged.
6838
+ * The only holder, always: an in-process host owns its executor. A close
6839
+ * removes nothing (#3756) — the query tables and projection marks stay in
6840
+ * the engine's database for the next open, as on a worker.
5652
6841
  */
5653
- async close(options = {}) {
5654
- let wiped = false;
5655
- if (options.wipeIfLast && this.wipe) {
5656
- await this.wipe(options.wipeIfLast.models);
5657
- wiped = true;
5658
- }
6842
+ async close() {
5659
6843
  this.closed = true;
5660
- return { wiped };
5661
- }
5662
- /**
5663
- * Remove this document from the named models' query tables on close.
5664
- *
5665
- * Injected because the query tables belong to the ENGINE, which an
5666
- * in-process store host has no handle on; over a worker host the same
5667
- * instruction travels as the worker's own `release` command.
5668
- */
5669
- onWipe(wipe) {
5670
- this.wipe = wipe;
5671
6844
  }
5672
- wipe = null;
5673
6845
  async holders() {
5674
6846
  return 1;
5675
6847
  }
@@ -5752,7 +6924,7 @@ var init_format2StoreHost = __esm({
5752
6924
  return null;
5753
6925
  }
5754
6926
  async close() {
5755
- if (this.disposed) return { wiped: false };
6927
+ if (this.disposed) return;
5756
6928
  this.disposed = true;
5757
6929
  this.closed = true;
5758
6930
  this.stopListening();
@@ -5765,7 +6937,6 @@ var init_format2StoreHost = __esm({
5765
6937
  this.port.close?.();
5766
6938
  } catch {
5767
6939
  }
5768
- return { wiped: false };
5769
6940
  }
5770
6941
  };
5771
6942
  }
@@ -7100,10 +8271,19 @@ var init_IncludeResolver = __esm({
7100
8271
  * for everything, which is every Node engine.
7101
8272
  */
7102
8273
  resolveEngine;
7103
- constructor(db, registry, resolveEngine) {
8274
+ /**
8275
+ * The documents a related row may come from (#3756): `_meta_doc_id IN
8276
+ * (...)` on every related-model statement, `1 = 0` for an empty list. A
8277
+ * closed large document keeps its query rows for its next open, so without
8278
+ * this an include would answer rows the parent statement already excludes.
8279
+ * Absent, related rows are not restricted by document.
8280
+ */
8281
+ documents;
8282
+ constructor(db, registry, resolveEngine, documents) {
7104
8283
  this.db = db;
7105
8284
  this.registry = registry ?? ModelRegistry.getInstance();
7106
8285
  this.resolveEngine = resolveEngine ?? (() => this.db);
8286
+ this.documents = documents ? [...documents] : null;
7107
8287
  }
7108
8288
  /** The engine `modelName`'s rows are read from. */
7109
8289
  async engineFor(modelName) {
@@ -7321,6 +8501,11 @@ var init_IncludeResolver = __esm({
7321
8501
  filterParams = translated.params;
7322
8502
  }
7323
8503
  }
8504
+ const documentCondition = this.documents === null ? null : translator.documentCondition([...this.documents]);
8505
+ if (documentCondition) {
8506
+ filterClause += ` AND ${documentCondition.sql}`;
8507
+ filterParams = [...filterParams, ...documentCondition.params];
8508
+ }
7324
8509
  const reservedParams = filterParams.length + 1;
7325
8510
  const chunkSize = Math.max(1, 100 - reservedParams);
7326
8511
  const db = await this.engineFor(spec.model);
@@ -7367,7 +8552,8 @@ var init_IncludeResolver = __esm({
7367
8552
  const translated = translator.translateFilter(extraFilter);
7368
8553
  filterParamCount = translated.params.length;
7369
8554
  }
7370
- const chunkSize = Math.max(1, 100 - filterParamCount);
8555
+ const documentParamCount = this.documents?.length ?? 0;
8556
+ const chunkSize = Math.max(1, 100 - filterParamCount - documentParamCount);
7371
8557
  const db = await this.engineFor(model);
7372
8558
  const allResults = [];
7373
8559
  for (let i = 0; i < values.length; i += chunkSize) {
@@ -7379,6 +8565,7 @@ var init_IncludeResolver = __esm({
7379
8565
  const options = {};
7380
8566
  if (extraOptions?.sort) options.sort = extraOptions.sort;
7381
8567
  if (extraOptions?.projection) options.projection = extraOptions.projection;
8568
+ if (this.documents !== null) options.documents = [...this.documents];
7382
8569
  const { sql, params } = translator.translateFind(filter, options);
7383
8570
  const rows = await db.query(sql, params);
7384
8571
  allResults.push(...rows);
@@ -7499,11 +8686,11 @@ function reindexFailure(modelConstructor, modelName, recordId, docId, error) {
7499
8686
  failure.cause = error;
7500
8687
  return failure;
7501
8688
  }
7502
- var Y7, import_ulid2, FORMAT2_ROW_PAGE, UniqueConstraintViolationError, RecordNotFoundError, SCHEMA_ACCESSORS_KEY, BaseModel2;
8689
+ var Y8, import_ulid2, FORMAT2_ROW_PAGE, UniqueConstraintViolationError, RecordNotFoundError, SCHEMA_ACCESSORS_KEY, BaseModel2;
7503
8690
  var init_BaseModel = __esm({
7504
8691
  "src/models/BaseModel.ts"() {
7505
8692
  "use strict";
7506
- Y7 = __toESM(require("yjs"), 1);
8693
+ Y8 = __toESM(require("yjs"), 1);
7507
8694
  import_ulid2 = require("ulid");
7508
8695
  init_StringSet();
7509
8696
  init_documentTypes();
@@ -8415,7 +9602,7 @@ var init_BaseModel = __esm({
8415
9602
  * record's key from the map — cannot drift apart (#3252).
8416
9603
  */
8417
9604
  static _stringSetMembersFromStored(stored) {
8418
- if (stored instanceof Y7.Map) {
9605
+ if (stored instanceof Y8.Map) {
8419
9606
  return Array.from(stored.keys());
8420
9607
  }
8421
9608
  if (Array.isArray(stored)) {
@@ -8583,7 +9770,7 @@ var init_BaseModel = __esm({
8583
9770
  }
8584
9771
  const extractStringSetValues = (value) => {
8585
9772
  if (!value) return [];
8586
- if (value instanceof Y7.Map) {
9773
+ if (value instanceof Y8.Map) {
8587
9774
  return Array.from(value.entries()).filter(([, isMember]) => Boolean(isMember)).map(([key]) => key);
8588
9775
  }
8589
9776
  if (Array.isArray(value)) {
@@ -8600,7 +9787,7 @@ var init_BaseModel = __esm({
8600
9787
  if (!recordId) continue;
8601
9788
  let itemData;
8602
9789
  const stringSetValuesByField = {};
8603
- if (recordData instanceof Y7.Map) {
9790
+ if (recordData instanceof Y8.Map) {
8604
9791
  itemData = {};
8605
9792
  const unknownFields = [];
8606
9793
  for (const [fieldKey, value] of recordData.entries()) {
@@ -8719,7 +9906,7 @@ var init_BaseModel = __esm({
8719
9906
  const buildUniqueKey2 = (recordData, fields) => buildUniqueKey(recordData, fields);
8720
9907
  const extractItemData = (key, recordData) => {
8721
9908
  let itemData;
8722
- if (recordData instanceof Y7.Map) {
9909
+ if (recordData instanceof Y8.Map) {
8723
9910
  itemData = {};
8724
9911
  const unknownFields = [];
8725
9912
  for (const [fieldKey, value] of recordData.entries()) {
@@ -8851,7 +10038,7 @@ var init_BaseModel = __esm({
8851
10038
  if (!recordData || !key) continue;
8852
10039
  const itemData = extractItemData(key, recordData);
8853
10040
  if (!itemData) continue;
8854
- if (change.action === "add" && recordData instanceof Y7.Map) {
10041
+ if (change.action === "add" && recordData instanceof Y8.Map) {
8855
10042
  Logger.verbose(
8856
10043
  `[${this.name}] Setting up observer on newly added nested YMap for record ${key} in document ${docId}`
8857
10044
  );
@@ -9013,7 +10200,7 @@ var init_BaseModel = __esm({
9013
10200
  `[${this.name}] Setting up observers on existing nested YMaps for ${modelName}/${docId}...`
9014
10201
  );
9015
10202
  for (const [recordId, recordData] of documentYMap.entries()) {
9016
- if (recordData instanceof Y7.Map) {
10203
+ if (recordData instanceof Y8.Map) {
9017
10204
  Logger.verbose(
9018
10205
  `[${this.name}] Setting up observer on existing nested YMap for record ${recordId} in document ${docId}`
9019
10206
  );
@@ -9077,7 +10264,7 @@ var init_BaseModel = __esm({
9077
10264
  ctx.notifyListeners(modelName);
9078
10265
  return;
9079
10266
  }
9080
- const entries = recordData instanceof Y7.Map ? Array.from(recordData.entries()) : Object.entries(recordData);
10267
+ const entries = recordData instanceof Y8.Map ? Array.from(recordData.entries()) : Object.entries(recordData);
9081
10268
  const rowData = {};
9082
10269
  for (const [fieldKey, value] of entries) {
9083
10270
  const fieldOptions = schema.fields?.get(fieldKey);
@@ -9426,7 +10613,7 @@ var init_BaseModel = __esm({
9426
10613
  const ownerId = constraintMap.get(uniqueKey);
9427
10614
  if (!ownerId) return null;
9428
10615
  const recordYMap = modelYMap?.get(ownerId);
9429
- if (!(recordYMap instanceof Y7.Map)) return null;
10616
+ if (!(recordYMap instanceof Y8.Map)) return null;
9430
10617
  const fields = this.getSchema?.()?.fields;
9431
10618
  const heldKey = this._buildKeyFromValues(
9432
10619
  constraint.fields,
@@ -9600,16 +10787,18 @@ var init_BaseModel = __esm({
9600
10787
  yDoc,
9601
10788
  targetYMap
9602
10789
  );
9603
- await this.saveFormat2({
9604
- store: target.store,
9605
- schema,
9606
- modelName,
9607
- docId: targetDocId,
9608
- permissionHint,
9609
- yDoc: target.yDoc,
9610
- modelYMap: target.modelYMap,
9611
- upsertOnField: options?.upsertOn ?? null
9612
- });
10790
+ await target.store.trackLocalWrite(
10791
+ () => this.saveFormat2({
10792
+ store: target.store,
10793
+ schema,
10794
+ modelName,
10795
+ docId: targetDocId,
10796
+ permissionHint,
10797
+ yDoc: target.yDoc,
10798
+ modelYMap: target.modelYMap,
10799
+ upsertOnField: options?.upsertOn ?? null
10800
+ })
10801
+ );
9613
10802
  this.clearLocalChanges();
9614
10803
  return;
9615
10804
  }
@@ -9708,7 +10897,7 @@ var init_BaseModel = __esm({
9708
10897
  Logger.debug(`[${modelName}.save] isUpdate: ${isUpdate}`);
9709
10898
  Logger.debug(`[${modelName}.save] recordYMap exists: ${!!recordYMap}`);
9710
10899
  if (!recordYMap) {
9711
- recordYMap = new Y7.Map();
10900
+ recordYMap = new Y8.Map();
9712
10901
  Logger.verbose(
9713
10902
  `[${modelName}] Creating new nested YMap for record ${this.id} in document ${targetDocId || "legacy"}`
9714
10903
  );
@@ -10174,6 +11363,7 @@ var init_BaseModel = __esm({
10174
11363
  stringSetChanges,
10175
11364
  stringSetFieldNames
10176
11365
  });
11366
+ this._format2Row = store.entryFor(modelName, this.id);
10177
11367
  await ops.commitLocalWrite(modelName, mutation, {
10178
11368
  model: modelName,
10179
11369
  recordId: this.id,
@@ -10219,11 +11409,34 @@ var init_BaseModel = __esm({
10219
11409
  applyOverlayMutation(modelYMap, mutation);
10220
11410
  }, FORMAT2_LOCAL_WRITE_ORIGIN);
10221
11411
  syncModelMeta(yDoc, modelName, schema);
11412
+ store.noteLocalPublish(yDoc);
10222
11413
  return true;
10223
11414
  });
10224
11415
  void planned;
10225
11416
  this._format2Row = store.entryFor(modelName, this.id);
10226
- await modelConstructor.projectFormat2Record(docId, this.id);
11417
+ await modelConstructor.projectFormat2LocalWrite(store, docId, modelName, this.id);
11418
+ }
11419
+ /**
11420
+ * Project the row a local write just committed, withdrawing the model's
11421
+ * projection mark if that fails (#3756). The mark outlives a close, so a
11422
+ * projection that failed under it would leave `query()` disagreeing with
11423
+ * `find()` until something rebuilt the model; dropping the mark makes the
11424
+ * next bind that something. The failure still reaches the caller.
11425
+ */
11426
+ static async projectFormat2LocalWrite(store, docId, modelName, recordId) {
11427
+ try {
11428
+ await this.projectFormat2Record(docId, recordId);
11429
+ } catch (error) {
11430
+ try {
11431
+ await store.forgetQueryProjection(modelName);
11432
+ } catch (markError) {
11433
+ Logger.error(
11434
+ `[format2] ${docId}: failed to withdraw the query-projection mark of ${modelName} after a local write's projection failed:`,
11435
+ markError
11436
+ );
11437
+ }
11438
+ throw error;
11439
+ }
10227
11440
  }
10228
11441
  /**
10229
11442
  * Mirror one merged-view row into the engine's per-model query tables, so
@@ -10241,67 +11454,60 @@ var init_BaseModel = __esm({
10241
11454
  * updates that arrive from the sync layer.
10242
11455
  */
10243
11456
  static async projectFormat2Record(docId, recordId, options = {}) {
11457
+ const modelConstructor = this;
11458
+ const projected = await modelConstructor.projectFormat2Ids(docId, [recordId]);
11459
+ if (projected && options.notify !== false) modelConstructor.notifyListeners();
11460
+ }
11461
+ /**
11462
+ * What this model's query tables look like, as data a store host can
11463
+ * project with. The one place the table, junction and column names
11464
+ * are decided; `null` when there is nothing to project into — no engine
11465
+ * behind this ORM instance, or no schema.
11466
+ */
11467
+ static format2ProjectionSpec(docId) {
10244
11468
  const modelConstructor = this;
10245
11469
  const schema = modelConstructor.getSchema?.();
10246
11470
  const modelName = schema?.options?.name;
10247
- const store = modelConstructor.getFormat2StoreAsync(docId);
10248
- if (!modelConstructor.dbInstance || !modelName || !store) return;
10249
- if (!store.isHydrated(modelName)) return;
10250
- const db = await modelConstructor.queryEngineFor([docId], modelName);
10251
- const permissionHint = modelConstructor.connectedDocuments.get(docId)?.permissionHint ?? "read-write";
10252
- const stringSetFieldNames = /* @__PURE__ */ new Set();
11471
+ if (!modelConstructor.dbInstance || !modelName) return null;
11472
+ const columns = [];
11473
+ const stringSets = [];
10253
11474
  for (const [fieldKey, fieldOptions] of schema.fields.entries()) {
10254
11475
  if (fieldOptions?.type === "stringset") {
10255
- stringSetFieldNames.add(fieldKey);
10256
- }
10257
- }
10258
- const notify = options.notify !== false;
10259
- const row = await store.read(modelName, recordId);
10260
- if (!row) {
10261
- await db.delete(modelName, recordId);
10262
- if (notify) modelConstructor.notifyListeners();
10263
- return;
10264
- }
10265
- const scalars = {};
10266
- for (const [key, value] of Object.entries(row)) {
10267
- if (stringSetFieldNames.has(key)) continue;
10268
- if (value === void 0) continue;
10269
- if (!schema.fields.has(key) && key !== "id" && key !== "type") continue;
10270
- scalars[key] = value;
10271
- }
10272
- await db.insert(modelName, {
10273
- ...scalars,
10274
- id: recordId,
10275
- type: modelName,
10276
- _meta_doc_id: docId,
10277
- _meta_permission_hint: permissionHint
10278
- });
10279
- for (const field of stringSetFieldNames) {
10280
- const members = (Array.isArray(row[field]) ? row[field] : []).map(String);
10281
- let current = [];
10282
- try {
10283
- const junction = quoteIdentifier(
10284
- modelConstructor.getDatabaseJunctionTableName(modelName, field)
10285
- );
10286
- const foreignKey = quoteIdentifier(`${modelName.toLowerCase()}_id`);
10287
- const rows = await db.query(
10288
- `SELECT value FROM ${junction} WHERE ${foreignKey} = ?`,
10289
- [recordId]
10290
- );
10291
- current = rows.map((entry) => String(entry.value));
10292
- } catch {
10293
- current = [];
10294
- }
10295
- const removals = current.filter((member) => !members.includes(member));
10296
- if (removals.length > 0) {
10297
- await db.removeStringSetValues(modelName, field, recordId, removals);
10298
- }
10299
- const additions = members.filter((member) => !current.includes(member));
10300
- if (additions.length > 0) {
10301
- await db.insertStringSetValues(modelName, field, recordId, additions);
11476
+ stringSets.push({
11477
+ field: fieldKey,
11478
+ table: modelConstructor.getDatabaseJunctionTableName(modelName, fieldKey)
11479
+ });
11480
+ } else {
11481
+ columns.push(fieldKey);
10302
11482
  }
10303
11483
  }
10304
- if (notify) modelConstructor.notifyListeners();
11484
+ return {
11485
+ model: modelName,
11486
+ table: modelConstructor.getDatabaseTableName(modelName),
11487
+ foreignKey: `${modelName.toLowerCase()}_id`,
11488
+ columns,
11489
+ stringSets,
11490
+ permissionHint: modelConstructor.connectedDocuments.get(docId)?.permissionHint ?? "read-write"
11491
+ };
11492
+ }
11493
+ /**
11494
+ * Tell this model's listeners that folded overlay changes reached its rows
11495
+ * and its query tables. Called once per model per fold batch by the
11496
+ * document binding; the projection itself ran on the store's host.
11497
+ * @internal
11498
+ */
11499
+ static notifyFormat2Folded() {
11500
+ this.notifyListeners();
11501
+ }
11502
+ /** Project `recordIds` through the store's host; whether anything was sent. */
11503
+ static async projectFormat2Ids(docId, recordIds) {
11504
+ const modelConstructor = this;
11505
+ const store = modelConstructor.getFormat2StoreAsync(docId);
11506
+ const spec = modelConstructor.format2ProjectionSpec(docId);
11507
+ if (!store || !spec) return false;
11508
+ if (!store.isHydrated(spec.model)) return false;
11509
+ await store.projectRows(spec.model, recordIds, spec);
11510
+ return true;
10305
11511
  }
10306
11512
  /**
10307
11513
  * Remove a PAGE of records from the query tables, without consulting the
@@ -10333,12 +11539,9 @@ var init_BaseModel = __esm({
10333
11539
  static async projectFormat2Records(docId, recordIds) {
10334
11540
  if (recordIds.length === 0) return;
10335
11541
  const modelConstructor = this;
10336
- for (const recordId of recordIds) {
10337
- await modelConstructor.projectFormat2Record(docId, recordId, {
10338
- notify: false
10339
- });
11542
+ if (await modelConstructor.projectFormat2Ids(docId, recordIds)) {
11543
+ modelConstructor.notifyListeners();
10340
11544
  }
10341
- modelConstructor.notifyListeners();
10342
11545
  }
10343
11546
  async delete() {
10344
11547
  const modelConstructor = this.constructor;
@@ -10379,14 +11582,16 @@ var init_BaseModel = __esm({
10379
11582
  sourceYDoc,
10380
11583
  modelConstructor.documentYMaps.get(`${docId}_${modelName}`) ?? sourceYDoc.getMap(modelName)
10381
11584
  );
10382
- await this.deleteFormat2({
10383
- store: target.store,
10384
- schema,
10385
- modelName,
10386
- docId,
10387
- yDoc: target.yDoc,
10388
- modelYMap: target.modelYMap
10389
- });
11585
+ await target.store.trackLocalWrite(
11586
+ () => this.deleteFormat2({
11587
+ store: target.store,
11588
+ schema,
11589
+ modelName,
11590
+ docId,
11591
+ yDoc: target.yDoc,
11592
+ modelYMap: target.modelYMap
11593
+ })
11594
+ );
10390
11595
  return;
10391
11596
  }
10392
11597
  if (!sourceYMap) {
@@ -10483,9 +11688,15 @@ var init_BaseModel = __esm({
10483
11688
  }
10484
11689
  applyOverlayMutation(modelYMap, mutation);
10485
11690
  }, FORMAT2_LOCAL_WRITE_ORIGIN);
11691
+ store.noteLocalPublish(yDoc);
10486
11692
  return existing;
10487
11693
  });
10488
- await modelConstructor.projectFormat2Record(ctx.docId, this.id);
11694
+ await modelConstructor.projectFormat2LocalWrite(
11695
+ store,
11696
+ ctx.docId,
11697
+ modelName,
11698
+ this.id
11699
+ );
10489
11700
  }
10490
11701
  // Get current state combining local changes with Yjs data
10491
11702
  getCurrentJSState() {
@@ -10665,13 +11876,16 @@ var init_BaseModel = __esm({
10665
11876
  limit: requestedLimit + 1
10666
11877
  };
10667
11878
  }
11879
+ const scope = this.documentsInScope(
11880
+ options?.documents
11881
+ );
11882
+ if (options?.documents == null) {
11883
+ translatorOptions = { ...translatorOptions || {}, documents: scope };
11884
+ }
10668
11885
  const translator = new DocumentQueryTranslator(modelName, schema.fields);
10669
11886
  const translatedQuery = translator.translateFind(filter, translatorOptions);
10670
11887
  Logger.verbose(`[${modelName}] Translated SQL:`, translatedQuery.sql);
10671
11888
  Logger.verbose(`[${modelName}] SQL params:`, translatedQuery.params);
10672
- const scope = this.documentsInScope(
10673
- options?.documents
10674
- );
10675
11889
  const queryEngine = await this.queryEngineFor(
10676
11890
  scope,
10677
11891
  modelName
@@ -10691,7 +11905,8 @@ var init_BaseModel = __esm({
10691
11905
  const resolver = new IncludeResolver2(
10692
11906
  queryEngine,
10693
11907
  ormContext.modelRegistry ?? void 0,
10694
- (relatedModel) => this.queryEngineFor(scope, relatedModel)
11908
+ (relatedModel) => this.queryEngineFor(scope, relatedModel),
11909
+ this.documentsInScope()
10695
11910
  );
10696
11911
  await resolver.resolve(results, options.include, 0, modelName);
10697
11912
  }
@@ -10842,7 +12057,9 @@ var init_BaseModel = __esm({
10842
12057
  );
10843
12058
  }
10844
12059
  this.assertFormat2Readable(
10845
- this.format2DocumentsInScope()
12060
+ this.format2DocumentsInScope(
12061
+ options.documents
12062
+ )
10846
12063
  );
10847
12064
  const schema = this.getSchema?.();
10848
12065
  if (!schema || !schema.options?.name) {
@@ -10879,10 +12096,16 @@ var init_BaseModel = __esm({
10879
12096
  }
10880
12097
  }
10881
12098
  }
12099
+ const aggregateScope = this.documentsInScope(options.documents);
12100
+ const connected = new Set(
12101
+ this.documentsInScope()
12102
+ );
12103
+ const sqlScope = options.documents == null ? aggregateScope : (typeof options.documents === "string" ? [options.documents] : options.documents).filter((docId) => connected.has(docId));
10882
12104
  const aggregationQuery = this.buildAggregationQuery(
10883
12105
  options,
10884
12106
  schema,
10885
- modelName
12107
+ modelName,
12108
+ sqlScope
10886
12109
  );
10887
12110
  Logger.verbose(
10888
12111
  `[${modelName}] Generated aggregation SQL:`,
@@ -10893,7 +12116,7 @@ var init_BaseModel = __esm({
10893
12116
  aggregationQuery.params
10894
12117
  );
10895
12118
  const aggregateEngine = await this.queryEngineFor(
10896
- this.documentsInScope(),
12119
+ aggregateScope,
10897
12120
  modelName
10898
12121
  );
10899
12122
  const results = await aggregateEngine.query(
@@ -10929,7 +12152,7 @@ var init_BaseModel = __esm({
10929
12152
  /**
10930
12153
  * Build SQL query for structured aggregation
10931
12154
  */
10932
- static buildAggregationQuery(options, schema, modelName) {
12155
+ static buildAggregationQuery(options, schema, modelName, documents) {
10933
12156
  const translator = new DocumentQueryTranslator(modelName, schema.fields);
10934
12157
  const regularGroupBy = [];
10935
12158
  const stringSetFacets = [];
@@ -10951,7 +12174,8 @@ var init_BaseModel = __esm({
10951
12174
  stringSetFacets,
10952
12175
  options,
10953
12176
  translator,
10954
- modelName
12177
+ modelName,
12178
+ documents
10955
12179
  );
10956
12180
  } else if (stringSetMemberships.length > 0 || regularGroupBy.length > 0) {
10957
12181
  return this.buildRegularAggregationQuery(
@@ -10959,12 +12183,26 @@ var init_BaseModel = __esm({
10959
12183
  stringSetMemberships,
10960
12184
  options,
10961
12185
  translator,
10962
- modelName
12186
+ modelName,
12187
+ documents
10963
12188
  );
10964
12189
  } else {
10965
12190
  throw new Error("Invalid aggregation configuration");
10966
12191
  }
10967
12192
  }
12193
+ /**
12194
+ * Append the document condition to an aggregation's WHERE (#3756): `AND`
12195
+ * after a filter, `WHERE` without one, nothing when `documents` is absent.
12196
+ */
12197
+ static withDocumentCondition(sql, params, translator, documents) {
12198
+ const condition = translator.documentCondition(documents);
12199
+ if (!condition) return { sql, params };
12200
+ const joiner = /\sWHERE\s/i.test(sql) ? "AND" : "WHERE";
12201
+ return {
12202
+ sql: `${sql} ${joiner} ${condition.sql}`,
12203
+ params: [...params, ...condition.params]
12204
+ };
12205
+ }
10968
12206
  /**
10969
12207
  * Get the proper database table name (should match database engine naming)
10970
12208
  */
@@ -10983,7 +12221,7 @@ var init_BaseModel = __esm({
10983
12221
  /**
10984
12222
  * Build query for StringSet facet counts
10985
12223
  */
10986
- static buildStringSetFacetQuery(stringSetFields, options, translator, modelName) {
12224
+ static buildStringSetFacetQuery(stringSetFields, options, translator, modelName, documents) {
10987
12225
  if (stringSetFields.length > 1) {
10988
12226
  throw new Error(
10989
12227
  "Multiple StringSet facet fields not supported in single aggregation"
@@ -11021,6 +12259,12 @@ var init_BaseModel = __esm({
11021
12259
  params = whereClause.params;
11022
12260
  }
11023
12261
  }
12262
+ ({ sql, params } = this.withDocumentCondition(
12263
+ sql,
12264
+ params,
12265
+ translator,
12266
+ documents
12267
+ ));
11024
12268
  sql += ` GROUP BY ${quotedJunctionTable}.${quotedValueColumn}`;
11025
12269
  if (options.sort) {
11026
12270
  const sortField = options.sort.field === "count" ? "COUNT(*)" : quoteIdentifier(options.sort.field);
@@ -11035,7 +12279,7 @@ var init_BaseModel = __esm({
11035
12279
  /**
11036
12280
  * Build query for regular field aggregation
11037
12281
  */
11038
- static buildRegularAggregationQuery(regularGroupBy, stringSetMemberships, options, translator, modelName) {
12282
+ static buildRegularAggregationQuery(regularGroupBy, stringSetMemberships, options, translator, modelName, documents) {
11039
12283
  const mainTable = this.getDatabaseTableName(modelName);
11040
12284
  const quotedMainTable = quoteIdentifier(mainTable);
11041
12285
  const recordIdColumn = `${modelName.toLowerCase()}_id`;
@@ -11141,6 +12385,12 @@ var init_BaseModel = __esm({
11141
12385
  params = params.concat(whereClause.params);
11142
12386
  }
11143
12387
  }
12388
+ ({ sql, params } = this.withDocumentCondition(
12389
+ sql,
12390
+ params,
12391
+ translator,
12392
+ documents
12393
+ ));
11144
12394
  const groupByParts = [];
11145
12395
  for (const field of regularGroupBy) {
11146
12396
  groupByParts.push(`${quotedMainTable}.${quoteIdentifier(field)}`);
@@ -11287,12 +12537,18 @@ var init_BaseModel = __esm({
11287
12537
  }
11288
12538
  const modelName = schema.options.name;
11289
12539
  Logger.verbose(`[${modelName}] Executing count:`, { filter, options });
12540
+ const scope = this.documentsInScope(
12541
+ options?.documents
12542
+ );
11290
12543
  const translator = new DocumentQueryTranslator(modelName, schema.fields);
11291
- const translatedQuery = translator.translateCount(filter, options);
12544
+ const translatedQuery = translator.translateCount(
12545
+ filter,
12546
+ options?.documents == null ? { documents: scope } : options
12547
+ );
11292
12548
  Logger.verbose(`[${modelName}] Translated count SQL:`, translatedQuery.sql);
11293
12549
  Logger.verbose(`[${modelName}] Count SQL params:`, translatedQuery.params);
11294
12550
  const countEngine = await this.queryEngineFor(
11295
- this.documentsInScope(options?.documents),
12551
+ scope,
11296
12552
  modelName
11297
12553
  );
11298
12554
  const results = await countEngine.query(
@@ -11366,7 +12622,7 @@ var init_BaseModel = __esm({
11366
12622
  );
11367
12623
  }
11368
12624
  for (const [recordId, recordYMap] of documentYMap.entries()) {
11369
- if (recordYMap instanceof Y7.Map) {
12625
+ if (recordYMap instanceof Y8.Map) {
11370
12626
  const instance = new this({ id: recordId });
11371
12627
  instance._metaDocId = docId;
11372
12628
  const connectedDoc = modelConstructor.connectedDocuments.get(docId);
@@ -11552,7 +12808,7 @@ var init_BaseModel = __esm({
11552
12808
  if (!recordYMap) {
11553
12809
  return null;
11554
12810
  }
11555
- if (recordYMap instanceof Y7.Map) {
12811
+ if (recordYMap instanceof Y8.Map) {
11556
12812
  const instance = new modelConstructor({
11557
12813
  id: recordId
11558
12814
  });
@@ -11828,7 +13084,7 @@ var init_BaseModel = __esm({
11828
13084
  for (const [fieldKey, fieldOptions] of schema.fields) {
11829
13085
  if (fieldOptions?.type !== "stringset") continue;
11830
13086
  const value = recordYMap.get(fieldKey);
11831
- if (value instanceof Y7.Map) {
13087
+ if (value instanceof Y8.Map) {
11832
13088
  this.observeStringSetMapOnce(value, docId);
11833
13089
  }
11834
13090
  }
@@ -12050,6 +13306,7 @@ __export(browser_exports, {
12050
13306
  FORMAT2_MANIFEST_VERSION: () => FORMAT2_MANIFEST_VERSION,
12051
13307
  FORMAT2_MIN_MANIFEST_VERSION: () => FORMAT2_MIN_MANIFEST_VERSION,
12052
13308
  FORMAT2_STORE_CHANNEL: () => FORMAT2_STORE_CHANNEL,
13309
+ FORMAT2_STORE_PROTOCOL: () => FORMAT2_STORE_PROTOCOL,
12053
13310
  Format2CompositeFieldError: () => Format2CompositeFieldError,
12054
13311
  Format2FoldBrokenError: () => Format2FoldBrokenError,
12055
13312
  Format2ModelNotHydratedError: () => Format2ModelNotHydratedError,
@@ -12058,6 +13315,7 @@ __export(browser_exports, {
12058
13315
  Format2RecordStoreAsync: () => Format2RecordStoreAsync,
12059
13316
  Format2RowCache: () => Format2RowCache,
12060
13317
  Format2StorageCapabilityError: () => Format2StorageCapabilityError,
13318
+ Format2StoreOutdatedError: () => Format2StoreOutdatedError,
12061
13319
  INGEST_CHUNK_LIMITS: () => INGEST_CHUNK_LIMITS,
12062
13320
  InProcessFormat2StoreHost: () => InProcessFormat2StoreHost,
12063
13321
  LogLevel: () => LogLevel,
@@ -12093,12 +13351,14 @@ __export(browser_exports, {
12093
13351
  configuredOfflineWindowDays: () => configuredOfflineWindowDays,
12094
13352
  createModelClass: () => createModelClass,
12095
13353
  decideDeferredReplay: () => decideDeferredReplay,
13354
+ decodeFoldedState: () => decodeFoldedState,
12096
13355
  decodeFormat2StoreError: () => decodeFormat2StoreError,
12097
13356
  defineModelSchema: () => defineModelSchema,
12098
13357
  detectEnvironment: () => detectEnvironment,
12099
13358
  discoverModelNames: () => discoverModelNames,
12100
13359
  discoverSchema: () => discoverSchema,
12101
13360
  dispatchFormat2StoreCommand: () => dispatchFormat2StoreCommand,
13361
+ encodeFoldedState: () => encodeFoldedState,
12102
13362
  encodeFormat2StoreError: () => encodeFormat2StoreError,
12103
13363
  encodeOverlayMutation: () => encodeOverlayMutation,
12104
13364
  estimateMaterializedBytes: () => estimateMaterializedBytes,
@@ -12127,10 +13387,12 @@ __export(browser_exports, {
12127
13387
  loadFormat2Snapshot: () => loadFormat2Snapshot,
12128
13388
  loadSchemaFromTomlString: () => loadSchemaFromTomlString,
12129
13389
  materializeOverlayRecord: () => materializeOverlayRecord,
13390
+ mergeFoldedStates: () => mergeFoldedStates,
12130
13391
  observeFormat2Overlay: () => observeFormat2Overlay,
12131
13392
  offlineReplayRecordKey: () => offlineReplayRecordKey,
12132
13393
  offlineWindowStatus: () => offlineWindowStatus,
12133
13394
  overlayFieldKey: () => overlayFieldKey,
13395
+ overlayKeysSince: () => overlayKeysSince,
12134
13396
  overlayMarkerKey: () => overlayMarkerKey,
12135
13397
  overlayMemberKey: () => overlayMemberKey,
12136
13398
  parseOverlayKey: () => parseOverlayKey,
@@ -12144,6 +13406,7 @@ __export(browser_exports, {
12144
13406
  readFormat2StoreMarks: () => readFormat2StoreMarks,
12145
13407
  readOverlayRecord: () => readOverlayRecord,
12146
13408
  readSnapshotChunk: () => readSnapshotChunk,
13409
+ readStateVector: () => readStateVector,
12147
13410
  registerFunctionDefault: () => registerFunctionDefault,
12148
13411
  resetJsBao: () => resetJsBao,
12149
13412
  resolveChunkOrdinal: () => resolveChunkOrdinal,
@@ -13015,7 +14278,7 @@ function planSnapshotHydration(input) {
13015
14278
  var Format2QueryScopeError = class _Format2QueryScopeError extends Error {
13016
14279
  constructor(model, documents) {
13017
14280
  super(
13018
- `A query on '${model}' spans documents held in different local databases (${documents.join(", ")}): scope it with \`documents\` to one side. Under the opfs engine a large document's query tables live in its own worker, apart from ordinary documents.`
14281
+ `A query on '${model}' spans documents held in different local stores (${documents.join(", ")}): an ordinary document's rows and a large document's cannot be read in one statement. Scope it with \`documents\` to one kind.`
13019
14282
  );
13020
14283
  this.model = model;
13021
14284
  this.documents = documents;
@@ -13037,6 +14300,7 @@ init_format2StoreHost();
13037
14300
  // src/engines/opfs/format2WorkerProtocol.ts
13038
14301
  init_format2StoreProtocol();
13039
14302
  var FORMAT2_WORKER_CHANNEL = "js-bao.format2.worker";
14303
+ var FORMAT2_STORE_PROTOCOL = 2;
13040
14304
  var FORMAT2_ENGINE_CHANNEL = "js-bao.format2.engine";
13041
14305
  function isFormat2WorkerResponse(data) {
13042
14306
  return typeof data === "object" && data !== null && (data.channel === FORMAT2_WORKER_CHANNEL || data.channel === FORMAT2_ENGINE_CHANNEL) && typeof data.id === "number" && typeof data.ok === "boolean";
@@ -13050,6 +14314,23 @@ var Format2WorkerOpenError = class _Format2WorkerOpenError extends Error {
13050
14314
  }
13051
14315
  code = "FORMAT2_WORKER_OPEN_FAILED";
13052
14316
  };
14317
+ function servedStoreProtocol(reply) {
14318
+ const version = reply?.storeProtocol;
14319
+ return typeof version === "number" && Number.isFinite(version) ? version : 1;
14320
+ }
14321
+ var Format2StoreOutdatedError = class _Format2StoreOutdatedError extends Error {
14322
+ constructor(served, required) {
14323
+ super(
14324
+ "the tab leading this store runs an older version of the app; reload it or close it"
14325
+ );
14326
+ this.served = served;
14327
+ this.required = required;
14328
+ this.name = "Format2StoreOutdatedError";
14329
+ Object.setPrototypeOf(this, _Format2StoreOutdatedError.prototype);
14330
+ }
14331
+ /** Stable across builds, so an app can branch on it rather than on text. */
14332
+ code = "FORMAT2_STORE_OUTDATED";
14333
+ };
13053
14334
  function decodeFormat2WorkerError(payload) {
13054
14335
  if (payload.code === "FORMAT2_WORKER_OPEN_FAILED") {
13055
14336
  const rebuilt = new Format2WorkerOpenError(
@@ -13066,6 +14347,7 @@ function decodeFormat2WorkerError(payload) {
13066
14347
  var FORMAT2_WORKER_MAX_SPAWNS = 12;
13067
14348
  var FORMAT2_WORKER_BACKOFF_MS = 250;
13068
14349
  var FORMAT2_WORKER_CLOSE_BOUND_MS = 2e3;
14350
+ var FORMAT2_LEGACY_OPEN_BOUND_MS = 2e3;
13069
14351
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13070
14352
  var WorkerLink = class {
13071
14353
  constructor(worker, label) {
@@ -13180,13 +14462,13 @@ async function openFormat2Worker(options) {
13180
14462
  const failures = [];
13181
14463
  const abandoned = () => new Format2WorkerOpenError(
13182
14464
  "worker-unavailable",
13183
- `the format-2 store for '${request.docId}' was not opened: the engine was destroyed`
14465
+ `the format-2 store '${request.poolName}' was not opened: the engine was destroyed`
13184
14466
  );
13185
14467
  for (let attempt = 1; attempt <= maxSpawns; attempt++) {
13186
14468
  if (signal?.aborted) throw abandoned();
13187
14469
  let link;
13188
14470
  try {
13189
- link = new WorkerLink(options.spawn(), request.docId);
14471
+ link = new WorkerLink(options.spawn(), request.poolName);
13190
14472
  } catch (error) {
13191
14473
  throw new Format2WorkerOpenError(
13192
14474
  "worker-unavailable",
@@ -13215,7 +14497,7 @@ async function openFormat2Worker(options) {
13215
14497
  }
13216
14498
  throw new Format2WorkerOpenError(
13217
14499
  "install-failed",
13218
- `the format-2 store for '${request.docId}' did not open after ${maxSpawns} worker spawn(s); last failure: ${failures[failures.length - 1] ?? "unknown"}`
14500
+ `the format-2 store '${request.poolName}' did not open after ${maxSpawns} worker spawn(s); last failure: ${failures[failures.length - 1] ?? "unknown"}`
13219
14501
  );
13220
14502
  }
13221
14503
  function attachFormat2Worker(port, label) {
@@ -13242,27 +14524,36 @@ function makeHandle(link, reply, spawns, failures) {
13242
14524
  get died() {
13243
14525
  return link.died;
13244
14526
  },
13245
- storedEpoch: () => control({ kind: "storedEpoch" }),
14527
+ storedEpoch: (docId) => control({ kind: "storedEpoch", docId }),
13246
14528
  engine: (command) => link.send(FORMAT2_ENGINE_CHANNEL, command),
13247
14529
  send: (channel, command) => link.send(channel, command),
13248
14530
  async serveStore(docId, clientId) {
13249
14531
  const channel = new MessageChannel();
13250
- await control(
14532
+ const served = await control(
13251
14533
  { kind: "serve", port: channel.port2, docId, ...clientId ? { clientId } : {} },
13252
14534
  [channel.port2]
13253
14535
  );
14536
+ const version = servedStoreProtocol(served);
14537
+ if (version < FORMAT2_STORE_PROTOCOL) {
14538
+ try {
14539
+ channel.port1.close();
14540
+ } catch {
14541
+ }
14542
+ throw new Format2StoreOutdatedError(version, FORMAT2_STORE_PROTOCOL);
14543
+ }
13254
14544
  const host = new RemoteFormat2StoreHost(channel.port1, docId);
13255
14545
  link.trackHost(host);
13256
14546
  return host;
13257
14547
  },
13258
- release: (docId, clientId, wipeIfLast) => control({
14548
+ release: (docId, clientId) => control({
13259
14549
  kind: "release",
13260
14550
  docId,
13261
- ...clientId ? { clientId } : {},
13262
- ...wipeIfLast ? { wipeIfLast } : {}
14551
+ ...clientId ? { clientId } : {}
13263
14552
  }),
13264
14553
  holders: (docId) => control({ kind: "holders", docId }),
13265
- attach: (attachedPort) => control({ kind: "attach", port: attachedPort }, [attachedPort]),
14554
+ attach: async (attachedPort) => {
14555
+ await control({ kind: "attach", port: attachedPort }, [attachedPort]);
14556
+ },
13266
14557
  async close(boundMs = FORMAT2_WORKER_CLOSE_BOUND_MS) {
13267
14558
  if (link.died) {
13268
14559
  link.terminate();
@@ -13503,8 +14794,44 @@ var Format2NamespaceAttachment = class {
13503
14794
  engine(command) {
13504
14795
  return this.send(FORMAT2_ENGINE_CHANNEL, command);
13505
14796
  }
13506
- storedEpoch() {
13507
- return this.send(FORMAT2_WORKER_CHANNEL, { kind: "storedEpoch" });
14797
+ /**
14798
+ * The epoch `docId`'s merged view is on, before it is bound (#3760: one
14799
+ * worker holds every document of the scope, so the command names one).
14800
+ */
14801
+ storedEpoch(docId) {
14802
+ return this.send(FORMAT2_WORKER_CHANNEL, { kind: "storedEpoch", docId });
14803
+ }
14804
+ /** Every large document the pool's store holds (#3760). */
14805
+ listDocuments() {
14806
+ return this.send(FORMAT2_WORKER_CHANNEL, { kind: "listDocuments" });
14807
+ }
14808
+ /**
14809
+ * What a PRE-AMENDMENT pool still owes for `docId` (#3760).
14810
+ *
14811
+ * Sent to a transient attachment over the LEGACY namespace, never to the
14812
+ * scope worker: it is the old database that holds the debt.
14813
+ */
14814
+ exportLegacy(docId) {
14815
+ return this.send(FORMAT2_WORKER_CHANNEL, { kind: "exportLegacy", docId });
14816
+ }
14817
+ /** Take that debt over, once, in one transaction (#3760). */
14818
+ importLegacy(options) {
14819
+ return this.send(FORMAT2_WORKER_CHANNEL, {
14820
+ kind: "importLegacy",
14821
+ docId: options.docId,
14822
+ pool: options.pool,
14823
+ legacyEpoch: options.legacyEpoch,
14824
+ rows: options.rows
14825
+ });
14826
+ }
14827
+ /** Delete exactly the imported batch from the legacy pool (#3760). */
14828
+ dropLegacy(docId, rows, discontinuities = []) {
14829
+ return this.send(FORMAT2_WORKER_CHANNEL, {
14830
+ kind: "dropLegacy",
14831
+ docId,
14832
+ rows,
14833
+ discontinuities
14834
+ });
13508
14835
  }
13509
14836
  holders(docId) {
13510
14837
  return this.send(FORMAT2_WORKER_CHANNEL, { kind: "holders", docId });
@@ -13519,6 +14846,21 @@ var Format2NamespaceAttachment = class {
13519
14846
  purge(docId, force) {
13520
14847
  return this.send(FORMAT2_WORKER_CHANNEL, { kind: "purge", docId, force });
13521
14848
  }
14849
+ /**
14850
+ * Purge exactly `documents`, in ONE queued command (#3760).
14851
+ *
14852
+ * The scope-wide wipe's single act. Like `purge` it goes out on whatever
14853
+ * this tab is attached to and touches rows rather than the pool; unlike a
14854
+ * loop of `purge`es, no other tab's `serve` can land between two of its
14855
+ * documents.
14856
+ */
14857
+ purgeAll(documents, force) {
14858
+ return this.send(FORMAT2_WORKER_CHANNEL, {
14859
+ kind: "purgeAll",
14860
+ documents: [...documents],
14861
+ force
14862
+ });
14863
+ }
13522
14864
  /**
13523
14865
  * Ask the worker to give up the pool, so its directory may be removed
13524
14866
  * (#3754).
@@ -13542,12 +14884,12 @@ var Format2NamespaceAttachment = class {
13542
14884
  get leads() {
13543
14885
  return this.attachment?.role === "leader" && (this.options.locks === null || this.leaderRelease !== null);
13544
14886
  }
13545
- release(docId, clientId, wipeIfLast) {
14887
+ /** End one session; answers how many sessions of `docId` remain (#3756). */
14888
+ release(docId, clientId) {
13546
14889
  return this.send(FORMAT2_WORKER_CHANNEL, {
13547
14890
  kind: "release",
13548
14891
  docId,
13549
- ...clientId ? { clientId } : {},
13550
- ...wipeIfLast ? { wipeIfLast } : {}
14892
+ ...clientId ? { clientId } : {}
13551
14893
  });
13552
14894
  }
13553
14895
  send(channel, command) {
@@ -13989,8 +15331,17 @@ var Format2NamespaceAttachment = class {
13989
15331
  /** @internal — used by {@link AttachedStoreSession}. */
13990
15332
  async openStorePort(docId, clientId) {
13991
15333
  const attachment = await this.ensureAttached();
13992
- const host = await attachment.handle.serveStore(docId, clientId);
13993
- return { host, generation: attachment.generation };
15334
+ try {
15335
+ const host = await attachment.handle.serveStore(docId, clientId);
15336
+ return { host, generation: attachment.generation };
15337
+ } catch (error) {
15338
+ if (error instanceof Format2StoreOutdatedError) {
15339
+ Logger.warn(
15340
+ `[OpfsEngine] ${this.options.namespace.poolName}: the worker serving this store speaks store protocol ${error.served}; this tab needs ${error.required}. ${docId} is not opened: ${error.message}.`
15341
+ );
15342
+ }
15343
+ throw error;
15344
+ }
13994
15345
  }
13995
15346
  /** @internal */
13996
15347
  forgetSession(session) {
@@ -14074,7 +15425,7 @@ var Format2NamespaceAttachment = class {
14074
15425
  if (current && !current.handle.died) {
14075
15426
  await withinBound(
14076
15427
  Promise.all(
14077
- leaving.map((session) => session.close({}).catch(() => void 0))
15428
+ leaving.map((session) => session.close().catch(() => void 0))
14078
15429
  ).then(() => void 0),
14079
15430
  this.options.closeBoundMs
14080
15431
  );
@@ -14232,19 +15583,18 @@ var AttachedStoreSession = class {
14232
15583
  this.opening = opening;
14233
15584
  await opening;
14234
15585
  }
14235
- close(options = {}) {
14236
- return this.closed ??= this.closeOnce(options);
15586
+ close() {
15587
+ return this.closed ??= this.closeOnce();
14237
15588
  }
14238
- async closeOnce(options) {
15589
+ async closeOnce() {
14239
15590
  this.attachment.forgetSession(this);
14240
- let wiped = false;
14241
15591
  try {
14242
- const reply = await this.attachment.release(
14243
- this.docId,
14244
- this.clientId,
14245
- options.wipeIfLast
14246
- );
14247
- wiped = reply.wiped;
15592
+ const reply = await this.attachment.release(this.docId, this.clientId);
15593
+ if (typeof reply?.remaining !== "number") {
15594
+ Logger.info(
15595
+ `[OpfsEngine] ${this.docId}: the worker answered release with no holder count (an older worker bundle); the session is ended.`
15596
+ );
15597
+ }
14248
15598
  } catch (error) {
14249
15599
  Logger.debug(
14250
15600
  `[OpfsEngine] ${this.docId}: release was not answered: ${error?.message ?? String(error)}`
@@ -14254,7 +15604,6 @@ var AttachedStoreSession = class {
14254
15604
  this.host = null;
14255
15605
  await host?.close().catch(() => {
14256
15606
  });
14257
- return { wiped };
14258
15607
  }
14259
15608
  };
14260
15609
 
@@ -14285,8 +15634,8 @@ function observeFormat2Lifecycle(target, handlers) {
14285
15634
  }
14286
15635
 
14287
15636
  // src/engines/opfs/storeNamespace.ts
14288
- function format2ClientLockName(namespace, clientId) {
14289
- return `${namespace.lockName}:client:${encodePart(clientId)}`;
15637
+ function format2ClientLockName(namespace, docId, clientId) {
15638
+ return `${namespace.lockName}:client:${encodePart(docId)}:${encodePart(clientId)}`;
14290
15639
  }
14291
15640
  function format2DocumentLockName(namespace, docId) {
14292
15641
  return `${namespace.lockName}:doc:${encodePart(docId)}`;
@@ -14294,12 +15643,13 @@ function format2DocumentLockName(namespace, docId) {
14294
15643
  function format2PoolDirectory(poolName) {
14295
15644
  return `.${poolName}`;
14296
15645
  }
14297
- function format2StoreNamespacePrefix(scope = {}) {
15646
+ function format2LegacyPoolPrefix(scope = {}) {
14298
15647
  const parts = [scope.server ?? "", scope.app ?? "", scope.user ?? ""];
14299
- return parts.some((part) => part !== "") ? PREFIX + parts.map(encodePart).join("-") + "-" : PREFIX;
15648
+ return parts.some((part) => part !== "") ? LEGACY_PREFIX + parts.map(encodePart).join("-") + "-" : LEGACY_PREFIX;
14300
15649
  }
14301
15650
  function format2DocumentIdFromPoolName(poolName, scope = {}) {
14302
- const prefix = format2StoreNamespacePrefix(scope);
15651
+ const prefix = format2LegacyPoolPrefix(scope);
15652
+ if (poolName.startsWith(SCOPE_PREFIX)) return null;
14303
15653
  if (!poolName.startsWith(prefix)) return null;
14304
15654
  const encoded = poolName.slice(prefix.length);
14305
15655
  if (encoded.length === 0 || encoded.includes("-")) return null;
@@ -14324,7 +15674,9 @@ function format2DocumentIdFromPoolName(poolName, scope = {}) {
14324
15674
  return null;
14325
15675
  }
14326
15676
  }
14327
- var PREFIX = "js-bao-format2-";
15677
+ var LEGACY_PREFIX = "js-bao-format2-";
15678
+ var SCOPE_PREFIX = "js-bao-format2s-";
15679
+ var UNSCOPED_NAME = `${SCOPE_PREFIX}unscoped`;
14328
15680
  function encodePart(value) {
14329
15681
  let out = "";
14330
15682
  for (const char of value) {
@@ -14337,10 +15689,20 @@ function encodePart(value) {
14337
15689
  }
14338
15690
  return out;
14339
15691
  }
14340
- function format2StoreNamespace(docId, scope = {}) {
15692
+ function format2StoreNamespace(scope = {}) {
15693
+ const parts = [scope.server ?? "", scope.app ?? "", scope.user ?? ""];
15694
+ const name = parts.some((part) => part !== "") ? SCOPE_PREFIX + parts.map(encodePart).join("-") : UNSCOPED_NAME;
15695
+ return {
15696
+ poolName: name,
15697
+ dbFile: "/store.sqlite3",
15698
+ lockName: name,
15699
+ poolLockName: `${name}:pool`
15700
+ };
15701
+ }
15702
+ function format2LegacyStoreNamespace(docId, scope = {}) {
14341
15703
  const parts = [scope.server ?? "", scope.app ?? "", scope.user ?? ""];
14342
15704
  const prefix = parts.some((part) => part !== "") ? parts.map(encodePart).join("-") + "-" : "";
14343
- const name = PREFIX + prefix + encodePart(docId);
15705
+ const name = LEGACY_PREFIX + prefix + encodePart(docId);
14344
15706
  return {
14345
15707
  poolName: name,
14346
15708
  dbFile: "/store.sqlite3",
@@ -14490,7 +15852,7 @@ var OpfsEngine = class {
14490
15852
  * refusal, raised before the models are wired, with one grep-able line.
14491
15853
  */
14492
15854
  async createFormat2StoreHost(docId, options = {}) {
14493
- const namespace = format2StoreNamespace(docId, options.scope ?? {});
15855
+ const namespace = format2StoreNamespace(options.scope ?? {});
14494
15856
  const documentHeld = await this.holdShared(
14495
15857
  format2DocumentLockName(namespace, docId)
14496
15858
  );
@@ -14500,7 +15862,7 @@ var OpfsEngine = class {
14500
15862
  };
14501
15863
  let entry;
14502
15864
  try {
14503
- entry = await this.ensureWorker(docId, options.scope);
15865
+ entry = await this.ensureWorker(namespace);
14504
15866
  } catch (error) {
14505
15867
  releaseDocument();
14506
15868
  const reason = error?.message ?? String(error);
@@ -14512,56 +15874,192 @@ var OpfsEngine = class {
14512
15874
  const clientId = options.clientId;
14513
15875
  let host;
14514
15876
  try {
14515
- host = entry.attachment.serveStore(docId, clientId);
14516
- await host.dispatch({ kind: "storedEpoch" });
15877
+ host = entry.attachment.serveStore(docId, clientId);
15878
+ await host.dispatch({ kind: "storedEpoch" });
15879
+ } catch (error) {
15880
+ releaseDocument();
15881
+ if (error instanceof Format2StoreOutdatedError) {
15882
+ await host?.close().catch(() => {
15883
+ });
15884
+ throw error;
15885
+ }
15886
+ const reason = error?.message ?? String(error);
15887
+ Logger.error(
15888
+ `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
15889
+ );
15890
+ throw new Format2StorageCapabilityError("not-persistent", 0, null, []);
15891
+ }
15892
+ await this.adoptLegacyPool(docId, options.scope ?? {}, entry).catch(
15893
+ (error) => {
15894
+ Logger.warn(
15895
+ `[OpfsEngine] ${docId}: the pre-amendment pool could not be adopted (${error?.message ?? String(error)}); its writes stay there and the next open tries again.`
15896
+ );
15897
+ }
15898
+ );
15899
+ entry.docIds.add(docId);
15900
+ const routing = this.docs.get(docId);
15901
+ if (routing && routing.entry === entry) routing.sessions++;
15902
+ else this.docs.set(docId, { entry, sessions: 1 });
15903
+ const held = clientId ? this.holdPresence(format2ClientLockName(namespace, docId, clientId)) : null;
15904
+ if (held) this.presence.add(held);
15905
+ const releasePresence = () => {
15906
+ if (held && this.presence.delete(held)) held();
15907
+ releaseDocument();
15908
+ };
15909
+ const engine = this;
15910
+ const closeOnce = async () => {
15911
+ const routing2 = engine.docs.get(docId);
15912
+ if (routing2 && routing2.entry === entry && --routing2.sessions <= 0) {
15913
+ entry.docIds.delete(docId);
15914
+ engine.docs.delete(docId);
15915
+ }
15916
+ releasePresence();
15917
+ await host.close();
15918
+ };
15919
+ let closing = null;
15920
+ return {
15921
+ dispatch: (command) => host.dispatch(command),
15922
+ directStore: () => null,
15923
+ holders: () => entry.attachment.holders(docId),
15924
+ clientIsLive: (other) => engine.probeLock(format2ClientLockName(namespace, docId, other)),
15925
+ // Forwarded, not answered here: the SESSION is what knows it has been
15926
+ // served again by a worker that never knew it, which is the moment the
15927
+ // document's projection marks stop vouching for anything (finding
15928
+ // 3431-R12).
15929
+ onReattached: (handler) => host.onReattached ? host.onReattached(handler) : () => {
15930
+ },
15931
+ close: () => (
15932
+ // Idempotent: a second close of one host must not give up a second
15933
+ // session, which would unroute a document another host still holds.
15934
+ closing ??= closeOnce()
15935
+ )
15936
+ };
15937
+ }
15938
+ /**
15939
+ * Take over a pre-amendment per-document pool's debt for `docId` (#3760).
15940
+ *
15941
+ * Before the 2026-09-24 amendment a large document's unacknowledged writes
15942
+ * lived in a pool named by document. This build names a pool by scope alone,
15943
+ * so those writes would simply stop being found — and they were accepted from
15944
+ * the app and never acknowledged by the server, which is the one thing the
15945
+ * intent's 0.7.0 rule does not let go of. The ROWS are not migrated (they
15946
+ * re-download through the ordinary cold start); the DEBT is.
15947
+ *
15948
+ * Runs on EVERY open, because a pre-amendment tab may still be running and
15949
+ * writing to that pool. With nothing new to take over it costs one directory
15950
+ * probe, one bounded spawn and two commands, and imports nothing. With no
15951
+ * legacy directory at all it costs the probe alone.
15952
+ *
15953
+ * The order is import-then-drop, and never the other way round: a crash
15954
+ * between them leaves the write in both databases, which the `_legacy_adopted`
15955
+ * ledger makes harmless (the next open imports nothing and drops the copy),
15956
+ * whereas dropping first would lose it outright.
15957
+ */
15958
+ async adoptLegacyPool(docId, scope, scopeEntry) {
15959
+ const legacy = format2LegacyStoreNamespace(docId, scope);
15960
+ await this.ensureRoot();
15961
+ const root = this.opfsRoot();
15962
+ if (!root) return;
15963
+ const directory = format2PoolDirectory(legacy.poolName);
15964
+ if (!await this.directoryExists(root, directory)) return;
15965
+ let entry;
15966
+ try {
15967
+ entry = await this.openLegacyWorker(legacy);
15968
+ } catch (error) {
15969
+ Logger.warn(
15970
+ `[OpfsEngine] ${docId}: the pre-amendment pool '${directory}' could not be opened (${error?.message ?? String(error)}); its writes stay there and the next open tries again.`
15971
+ );
15972
+ return;
15973
+ }
15974
+ try {
15975
+ const rows = await entry.attachment.exportLegacy(docId);
15976
+ const report = await scopeEntry.attachment.importLegacy({
15977
+ docId,
15978
+ pool: legacy.poolName,
15979
+ legacyEpoch: rows.epoch,
15980
+ rows
15981
+ });
15982
+ if (report.imported.length > 0) {
15983
+ Logger.info(
15984
+ `[OpfsEngine] ${docId}: adopted ${report.imported.length} unacknowledged write(s) from the pre-amendment pool '${directory}', made on epoch ${rows.epoch}.`
15985
+ );
15986
+ }
15987
+ if (this.__failLegacyDropOnce) {
15988
+ this.__failLegacyDropOnce = false;
15989
+ throw new Error("the drop was interrupted");
15990
+ }
15991
+ if (report.settled.length > 0 || report.discontinuities.length > 0) {
15992
+ await entry.attachment.dropLegacy(
15993
+ docId,
15994
+ report.settled,
15995
+ report.discontinuities
15996
+ );
15997
+ }
15998
+ } finally {
15999
+ await this.closeLegacyWorker(legacy, entry);
16000
+ }
16001
+ }
16002
+ /**
16003
+ * A transient attachment over a legacy namespace, bounded (#3760).
16004
+ *
16005
+ * Deliberately NOT `ensureWorker`: this worker is not the scope's, it must
16006
+ * not be remembered in `workers` (a leader change has nothing to hand over
16007
+ * here), and its open budget is capped so a legacy pool another tab holds
16008
+ * costs an open two seconds rather than the engine's whole budget.
16009
+ */
16010
+ async openLegacyWorker(namespace) {
16011
+ const openTimeoutMs = Math.min(
16012
+ this.openTimeoutMs,
16013
+ FORMAT2_LEGACY_OPEN_BOUND_MS
16014
+ );
16015
+ const attachment = new Format2NamespaceAttachment({
16016
+ namespace,
16017
+ clientId: this.tabId,
16018
+ openRequest: {
16019
+ poolName: namespace.poolName,
16020
+ dbFile: namespace.dbFile,
16021
+ poolLockName: namespace.poolLockName,
16022
+ openTimeoutMs,
16023
+ ...this.wasmURL ? { wasmURL: this.wasmURL } : {}
16024
+ },
16025
+ spawnWorker: this.spawn,
16026
+ connectBroker: this.connectBroker,
16027
+ brokerHint: this.brokerHint,
16028
+ locks: this.locks(),
16029
+ openTimeoutMs,
16030
+ closeBoundMs: this.internals.closeBoundMs ?? FORMAT2_WORKER_CLOSE_BOUND_MS,
16031
+ sleep: this.internals.sleep,
16032
+ // One spawn: a legacy pool that will not open is not worth retrying
16033
+ // inside an open, and the next open tries again anyway.
16034
+ maxSpawns: 1,
16035
+ backoffMs: this.internals.backoffMs,
16036
+ signal: this.teardown.signal
16037
+ });
16038
+ this.attachments.add(attachment);
16039
+ try {
16040
+ await attachment.ensureAttached();
14517
16041
  } catch (error) {
14518
- releaseDocument();
14519
- const reason = error?.message ?? String(error);
14520
- Logger.error(
14521
- `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
14522
- );
14523
- throw new Format2StorageCapabilityError("not-persistent", 0, null, []);
16042
+ this.attachments.delete(attachment);
16043
+ await attachment.destroy().catch(() => {
16044
+ });
16045
+ throw error;
14524
16046
  }
14525
- entry.docIds.add(docId);
14526
- const routing = this.docs.get(docId);
14527
- if (routing && routing.entry === entry) routing.sessions++;
14528
- else this.docs.set(docId, { entry, sessions: 1 });
14529
- const held = clientId ? this.holdPresence(format2ClientLockName(namespace, clientId)) : null;
14530
- if (held) this.presence.add(held);
14531
- const releasePresence = () => {
14532
- if (held && this.presence.delete(held)) held();
14533
- releaseDocument();
14534
- };
14535
- const engine = this;
14536
- const closeOnce = async (closeOptions) => {
14537
- const routing2 = engine.docs.get(docId);
14538
- if (routing2 && routing2.entry === entry && --routing2.sessions <= 0) {
14539
- entry.docIds.delete(docId);
14540
- engine.docs.delete(docId);
14541
- }
14542
- releasePresence();
14543
- return host.close(closeOptions);
14544
- };
14545
- let closing = null;
14546
16047
  return {
14547
- dispatch: (command) => host.dispatch(command),
14548
- directStore: () => null,
14549
- holders: () => entry.attachment.holders(docId),
14550
- clientIsLive: (other) => engine.probeLock(format2ClientLockName(namespace, other)),
14551
- // Forwarded, not answered here: the SESSION is what knows it has been
14552
- // served again by a worker that never knew it, which is the moment the
14553
- // document's projection marks stop vouching for anything (finding
14554
- // 3431-R12).
14555
- onReattached: (handler) => host.onReattached ? host.onReattached(handler) : () => {
14556
- },
14557
- close: (closeOptions = {}) => (
14558
- // Idempotent: a second close of one host must not give up a second
14559
- // session, which would unroute a document another host still holds and
14560
- // let its `release` wipe the query tables as the last holder.
14561
- closing ??= closeOnce(closeOptions)
14562
- )
16048
+ poolName: namespace.poolName,
16049
+ attachment,
16050
+ engine: new RemoteQueryEngine(attachment),
16051
+ docIds: /* @__PURE__ */ new Set()
14563
16052
  };
14564
16053
  }
16054
+ /** Pause and let go of a transient legacy worker; never remove its pool. */
16055
+ async closeLegacyWorker(namespace, entry) {
16056
+ this.attachments.delete(entry.attachment);
16057
+ await entry.attachment.destroy().catch((error) => {
16058
+ Logger.debug(
16059
+ `[OpfsEngine] the pre-amendment pool '${namespace.poolName}' did not close cleanly: ${error?.message ?? String(error)}`
16060
+ );
16061
+ });
16062
+ }
14565
16063
  /**
14566
16064
  * The epoch `docId`'s worker-side store follows, asked before the document
14567
16065
  * is bound. `null` only when the worker cannot be reached; a fresh store
@@ -14569,12 +16067,25 @@ var OpfsEngine = class {
14569
16067
  */
14570
16068
  async format2StoredEpoch(docId, scope) {
14571
16069
  try {
14572
- const entry = await this.ensureWorker(docId, scope);
14573
- return await entry.attachment.storedEpoch();
16070
+ const entry = await this.ensureWorker(format2StoreNamespace(scope ?? {}));
16071
+ return await entry.attachment.storedEpoch(docId);
14574
16072
  } catch {
14575
16073
  return null;
14576
16074
  }
14577
16075
  }
16076
+ /**
16077
+ * Every large document this scope's store holds, asked of its worker
16078
+ * (#3760).
16079
+ *
16080
+ * The wipe's first question — a scope store is one pool holding many
16081
+ * documents, so "what is in it" is no longer readable off the OPFS root's
16082
+ * directory names — and the only window onto a shared store's contents from
16083
+ * outside it.
16084
+ */
16085
+ async format2ListDocuments(scope) {
16086
+ const entry = await this.ensureWorker(format2StoreNamespace(scope ?? {}));
16087
+ return entry.attachment.listDocuments();
16088
+ }
14578
16089
  // ── Removing a document's store, and the pool holding it (#3754) ──
14579
16090
  /**
14580
16091
  * Remove `docId`'s local data, and its pool's directory once it holds
@@ -14599,21 +16110,26 @@ var OpfsEngine = class {
14599
16110
  );
14600
16111
  }
14601
16112
  await this.ensureRoot();
14602
- const namespace = format2StoreNamespace(docId, options.scope ?? {});
16113
+ const namespace = format2StoreNamespace(options.scope ?? {});
14603
16114
  if (this.docs.has(docId)) {
14604
16115
  Logger.warn(
14605
16116
  `[OpfsEngine] ${docId}: purge refused: held (this tab has it open).`
14606
16117
  );
14607
16118
  return { purged: false, reason: "held" };
14608
16119
  }
16120
+ return this.purgeInNamespace(docId, namespace, options.force);
16121
+ }
16122
+ /**
16123
+ * Purge `docId` out of ONE named store, under its presence lock.
16124
+ *
16125
+ * Parameterized by namespace since #3760, because there are now two kinds
16126
+ * of store to reach: the scope store an evict and an open both use, and a
16127
+ * pre-amendment per-document pool, which only the wipe does.
16128
+ */
16129
+ async purgeInNamespace(docId, namespace, force) {
14609
16130
  const taken = await this.withExclusive(
14610
16131
  format2DocumentLockName(namespace, docId),
14611
- () => this.purgeUnderDocumentLock(
14612
- docId,
14613
- namespace,
14614
- options.scope ?? {},
14615
- options.force
14616
- )
16132
+ () => this.purgeUnderDocumentLock(docId, namespace, force)
14617
16133
  );
14618
16134
  if (!taken.got) {
14619
16135
  Logger.warn(
@@ -14624,13 +16140,28 @@ var OpfsEngine = class {
14624
16140
  return taken.value;
14625
16141
  }
14626
16142
  /**
14627
- * Remove every large document of one scope except `keep` (#3754).
16143
+ * Remove every large document of one scope except `keep` (#3754, re-shaped
16144
+ * by #3760).
14628
16145
  *
14629
- * Driven by the OPFS root's ENTRIES rather than by anything in memory, which
14630
- * is the only thing that reaches a document this session never opened and a
14631
- * pool left behind before a purge existed (finding R3). A scoped prefix ends
14632
- * in the separator and no encoded part contains one, so it can match neither
14633
- * another scope's pool nor an unscoped one (edge E4).
16146
+ * Two families of store to reach, and they are swept differently because
16147
+ * they are shaped differently.
16148
+ *
16149
+ * The SCOPE store is one pool holding many documents, so what is in it is
16150
+ * no longer readable off the OPFS root's directory names: the worker is
16151
+ * asked (`listDocuments`). Each document that is not kept then has its
16152
+ * presence lock taken EXCLUSIVE with `ifAvailable` ON THE MAIN THREAD,
16153
+ * BEFORE any worker command — because a frozen tab after a leader change
16154
+ * registers no session anywhere, and that lock is the only thing that
16155
+ * speaks for it (finding 3760-R1, purge-3754 behavior 9b). Only the
16156
+ * documents whose locks were granted are purged, in one queued `purgeAll`
16157
+ * command, and the locks are held across the command, the retire and the
16158
+ * directory removal.
16159
+ *
16160
+ * A PRE-AMENDMENT per-document pool is still exactly one document's data,
16161
+ * and goes through #3754's per-pool path unchanged, on the legacy
16162
+ * namespace: a wipe is the user asking for their data to go, and the docs
16163
+ * promise it reaches a store an older client left behind (decision
16164
+ * 3760-SO-08).
14634
16165
  */
14635
16166
  async format2PurgeAll(options) {
14636
16167
  if (this.destroyed) {
@@ -14640,29 +16171,27 @@ var OpfsEngine = class {
14640
16171
  }
14641
16172
  await this.ensureRoot();
14642
16173
  const scope = options.scope ?? {};
14643
- const prefix = format2PoolDirectory(format2StoreNamespacePrefix(scope));
14644
- const keepDirectories = new Set(
14645
- (options.keep ?? []).map(
14646
- (id) => format2PoolDirectory(format2StoreNamespace(id, scope).poolName)
14647
- )
14648
- );
14649
16174
  const removed = [];
14650
16175
  const skipped = [];
14651
- for (const directory of await this.listPoolDirectories(prefix)) {
14652
- if (keepDirectories.has(directory)) {
14653
- skipped.push({ pool: directory, reason: "keep" });
14654
- continue;
14655
- }
16176
+ const purged = [];
16177
+ const namespace = format2StoreNamespace(scope);
16178
+ const scopeDirectory = format2PoolDirectory(namespace.poolName);
16179
+ const legacyPrefix = format2PoolDirectory(format2LegacyPoolPrefix(scope));
16180
+ for (const directory of await this.listPoolDirectories(legacyPrefix)) {
16181
+ if (directory === scopeDirectory) continue;
14656
16182
  const poolName = directory.slice(1);
14657
16183
  const docId = format2DocumentIdFromPoolName(poolName, scope);
14658
16184
  if (docId === null) {
14659
16185
  skipped.push({ pool: directory, reason: "unrecognized" });
14660
16186
  continue;
14661
16187
  }
14662
- const result = await this.format2Purge(docId, {
14663
- force: options.force,
14664
- scope
14665
- });
16188
+ if ((options.keep ?? []).includes(docId)) {
16189
+ skipped.push({ pool: directory, reason: "keep" });
16190
+ continue;
16191
+ }
16192
+ const legacy = format2LegacyStoreNamespace(docId, scope);
16193
+ const result = await this.purgeInNamespace(docId, legacy, options.force);
16194
+ if (result.purged) purged.push(docId);
14666
16195
  if (result.purged && result.poolRemoved) {
14667
16196
  removed.push(directory);
14668
16197
  continue;
@@ -14670,13 +16199,170 @@ var OpfsEngine = class {
14670
16199
  const reason = result.purged ? "kept-pool" : result.reason;
14671
16200
  skipped.push({ pool: directory, reason });
14672
16201
  Logger.warn(
14673
- `[OpfsEngine] the sweep skipped the pool '${directory}': ${reason}.`
16202
+ `[OpfsEngine] the sweep skipped the pre-amendment pool '${directory}': ${reason}.`
14674
16203
  );
14675
16204
  }
16205
+ await this.sweepScopeStore(namespace, options, {
16206
+ removed,
16207
+ skipped,
16208
+ purged
16209
+ });
14676
16210
  Logger.info(
14677
- `[OpfsEngine] large-document sweep removed ${removed.length} pool(s), skipped ${skipped.length}.`
16211
+ `[OpfsEngine] large-document sweep purged ${purged.length} document(s), removed ${removed.length} pool(s), skipped ${skipped.length}.`
16212
+ );
16213
+ return { removed, skipped, purged };
16214
+ }
16215
+ /**
16216
+ * The scope store's half of a sweep: its documents, its locks, one command
16217
+ * (#3760).
16218
+ */
16219
+ async sweepScopeStore(namespace, options, report) {
16220
+ const directory = format2PoolDirectory(namespace.poolName);
16221
+ const root = this.opfsRoot();
16222
+ if (root && !await this.directoryExists(root, directory)) {
16223
+ Logger.debug(
16224
+ `[OpfsEngine] the scope store '${directory}' holds nothing (no pool directory), so the sweep skips it.`
16225
+ );
16226
+ return;
16227
+ }
16228
+ let entry;
16229
+ try {
16230
+ entry = await this.ensureWorker(namespace);
16231
+ } catch (error) {
16232
+ const reason = error?.message ?? String(error);
16233
+ Logger.warn(
16234
+ `[OpfsEngine] the scope store '${directory}' could not be reached (${reason}), so nothing in it was swept.`
16235
+ );
16236
+ report.skipped.push({ pool: directory, reason: "unreachable" });
16237
+ return;
16238
+ }
16239
+ let stored;
16240
+ try {
16241
+ stored = await entry.attachment.listDocuments();
16242
+ } catch (error) {
16243
+ const reason = error?.message ?? String(error);
16244
+ Logger.warn(
16245
+ `[OpfsEngine] the scope store '${directory}' could not list its documents (${reason}), so nothing in it was swept.`
16246
+ );
16247
+ report.skipped.push({ pool: directory, reason: "unreachable" });
16248
+ return;
16249
+ }
16250
+ const keep = new Set(options.keep ?? []);
16251
+ const wanted = [];
16252
+ for (const docId of stored) {
16253
+ if (keep.has(docId)) report.skipped.push({ pool: docId, reason: "keep" });
16254
+ else wanted.push(docId);
16255
+ }
16256
+ await this.withExclusiveAll(
16257
+ wanted.map((docId) => format2DocumentLockName(namespace, docId)),
16258
+ async (granted) => {
16259
+ const acquired = wanted.filter((_, at) => granted[at]);
16260
+ for (const [at, docId] of wanted.entries()) {
16261
+ if (granted[at]) continue;
16262
+ Logger.warn(
16263
+ `[OpfsEngine] ${docId}: the sweep skipped it: held (another tab holds its presence lock, so it still has the document open).`
16264
+ );
16265
+ report.skipped.push({ pool: docId, reason: "held" });
16266
+ }
16267
+ let reply;
16268
+ try {
16269
+ reply = await entry.attachment.purgeAll(acquired, options.force);
16270
+ } catch (error) {
16271
+ const reason = error?.message ?? String(error);
16272
+ Logger.warn(
16273
+ `[OpfsEngine] the scope store '${directory}' could not be swept (${reason}).`
16274
+ );
16275
+ report.skipped.push({ pool: directory, reason: "unreachable" });
16276
+ return;
16277
+ }
16278
+ report.purged.push(...reply.purged);
16279
+ for (const kept of reply.kept) {
16280
+ Logger.warn(
16281
+ `[OpfsEngine] ${kept.docId}: the sweep kept it: ${kept.reason}` + (kept.reason === "pending" ? ` (${kept.pendingOps ?? 0} unacknowledged write(s); sweep with force to drop them)` : ` (${kept.holders ?? 0} session(s) on the pool's worker)`) + "."
16282
+ );
16283
+ report.skipped.push({ pool: kept.docId, reason: kept.reason });
16284
+ }
16285
+ if (reply.remainingDocuments > 0) {
16286
+ Logger.debug(
16287
+ `[OpfsEngine] the scope store still holds ${reply.remainingDocuments} document(s), so '${directory}' stays.`
16288
+ );
16289
+ report.skipped.push({ pool: directory, reason: "kept-pool" });
16290
+ return;
16291
+ }
16292
+ const owing = await this.legacyPoolsStillOnDisk(reply.legacyPools ?? []);
16293
+ if (owing.length > 0) {
16294
+ Logger.info(
16295
+ `[OpfsEngine] the scope store '${directory}' holds no documents, but it stays: it is the only record that ${owing.join(", ")} has already been adopted from.`
16296
+ );
16297
+ report.skipped.push({ pool: directory, reason: "kept-pool" });
16298
+ return;
16299
+ }
16300
+ const gone = await this.retireAndRemove(namespace, entry, directory);
16301
+ if (gone) report.removed.push(directory);
16302
+ else report.skipped.push({ pool: directory, reason: "kept-pool" });
16303
+ }
16304
+ );
16305
+ }
16306
+ /**
16307
+ * Retire a namespace's worker and remove its directory, under the leader
16308
+ * lock this tab already holds (#3754, lifted out for the scope sweep).
16309
+ */
16310
+ async retireAndRemove(namespace, entry, directory) {
16311
+ if (!entry.attachment.leads) {
16312
+ Logger.debug(
16313
+ `[OpfsEngine] another tab leads '${directory}', so its directory is left to that tab.`
16314
+ );
16315
+ return false;
16316
+ }
16317
+ let retired;
16318
+ try {
16319
+ retired = await entry.attachment.retire();
16320
+ } catch (error) {
16321
+ Logger.warn(
16322
+ `[OpfsEngine] '${directory}' could not be retired (${error?.message ?? String(error)}), so its directory stays.`
16323
+ );
16324
+ return false;
16325
+ }
16326
+ if (!retired.retired) {
16327
+ Logger.warn(
16328
+ `[OpfsEngine] '${directory}' was not retired \u2014 ${retired.holders} session(s) on its worker still hold a document open, so its directory stays.`
16329
+ );
16330
+ return false;
16331
+ }
16332
+ const poolRemoved = await this.removePoolDirectory(
16333
+ namespace,
16334
+ namespace.poolName,
16335
+ entry
14678
16336
  );
14679
- return { removed, skipped };
16337
+ this.workers.delete(namespace.poolName);
16338
+ this.attachments.delete(entry.attachment);
16339
+ await entry.attachment.destroy({ alreadyRetired: true }).catch(() => {
16340
+ });
16341
+ return poolRemoved;
16342
+ }
16343
+ /**
16344
+ * Hold every name in `names` exclusively (with `ifAvailable`) for the
16345
+ * length of `run`, telling it which were granted (#3760).
16346
+ *
16347
+ * Nested rather than sequential: the whole point is that the locks are all
16348
+ * held AT ONCE, across the one `purgeAll` command, the retire and the
16349
+ * directory removal, so a tab cannot open a document between the moment the
16350
+ * sweep decided to take it and the moment its pool is gone.
16351
+ */
16352
+ async withExclusiveAll(names, run2) {
16353
+ const granted = [];
16354
+ const next = async (at) => {
16355
+ if (at >= names.length) return run2(granted);
16356
+ const taken = await this.withExclusive(names[at], async () => {
16357
+ granted[at] = true;
16358
+ await next(at + 1);
16359
+ });
16360
+ if (!taken.got) {
16361
+ granted[at] = false;
16362
+ await next(at + 1);
16363
+ }
16364
+ };
16365
+ await next(0);
14680
16366
  }
14681
16367
  /**
14682
16368
  * The purge itself, with this tab holding the document lock exclusively.
@@ -14686,16 +16372,25 @@ var OpfsEngine = class {
14686
16372
  * `routing.sessions` say what THIS tab serves, while the worker's session map
14687
16373
  * says what every tab does (finding 3754-SO-03).
14688
16374
  */
14689
- async purgeUnderDocumentLock(docId, namespace, scope, force) {
16375
+ async purgeUnderDocumentLock(docId, namespace, force) {
14690
16376
  const existing = this.workers.get(namespace.poolName);
14691
16377
  const attached = existing && !existing.attachment.died ? existing : null;
14692
16378
  if (!attached) {
14693
- return this.purgeWithoutAttachment(docId, namespace, scope, force);
16379
+ return this.purgeWithoutAttachment(docId, namespace, force);
14694
16380
  }
14695
16381
  return this.purgeThroughAttachment(docId, namespace, attached, force);
14696
16382
  }
14697
- /** No attachment for the namespace: what is on disk decides what happens. */
14698
- async purgeWithoutAttachment(docId, namespace, scope, force) {
16383
+ /**
16384
+ * No attachment for the namespace: what is on disk decides what happens.
16385
+ *
16386
+ * There is no forced shortcut that removes the directory instead of purging
16387
+ * the store. #3754 had one, on the premise that "a per-document pool IS
16388
+ * exactly this document's data" — under one store per scope that premise is
16389
+ * false, and taking the directory would take every other document of the
16390
+ * user with it. The worker is spawned and the rows purged through it,
16391
+ * forced or not (decision 3760-SO-06).
16392
+ */
16393
+ async purgeWithoutAttachment(docId, namespace, force) {
14699
16394
  const directory = format2PoolDirectory(namespace.poolName);
14700
16395
  const root = this.opfsRoot();
14701
16396
  if (root) {
@@ -14706,24 +16401,10 @@ var OpfsEngine = class {
14706
16401
  );
14707
16402
  return { purged: true, nothingStored: true, poolRemoved: false };
14708
16403
  }
14709
- if (force) {
14710
- const under = await this.withExclusive(
14711
- namespace.lockName,
14712
- () => this.removePoolDirectory(namespace, docId, null)
14713
- );
14714
- if (under.got && under.value) {
14715
- return { purged: true, poolRemoved: true, pendingOps: 0 };
14716
- }
14717
- if (under.got) {
14718
- Logger.warn(
14719
- `[OpfsEngine] ${docId}: its pool directory could not be removed directly, so its store is purged through a worker instead.`
14720
- );
14721
- }
14722
- }
14723
16404
  }
14724
16405
  let entry;
14725
16406
  try {
14726
- entry = await this.ensureWorker(docId, scope);
16407
+ entry = await this.ensureWorker(namespace);
14727
16408
  } catch (error) {
14728
16409
  const reason = error?.message ?? String(error);
14729
16410
  Logger.warn(
@@ -14770,6 +16451,13 @@ var OpfsEngine = class {
14770
16451
  );
14771
16452
  return { ...purgedReport(reply), poolRemoved: false };
14772
16453
  }
16454
+ const owing = await this.legacyPoolsStillOnDisk(reply.legacyPools ?? []);
16455
+ if (owing.length > 0) {
16456
+ Logger.info(
16457
+ `[OpfsEngine] ${docId}: its store holds no documents, but its directory stays: it is the only record that ${owing.join(", ")} has already been adopted from. A wipe of this user's data reclaims both.`
16458
+ );
16459
+ return { ...purgedReport(reply), poolRemoved: false };
16460
+ }
14773
16461
  if (!entry.attachment.leads) {
14774
16462
  Logger.debug(
14775
16463
  `[OpfsEngine] ${docId}: another tab leads its pool, so its directory is left to that tab.`
@@ -14860,10 +16548,10 @@ var OpfsEngine = class {
14860
16548
  /**
14861
16549
  * Resolve `navigator.storage.getDirectory()` once, if this platform has it.
14862
16550
  *
14863
- * Awaited at the top of every purge rather than in the constructor: an engine
14864
- * is built before anybody has asked it for anything, and a rejected promise
14865
- * kept on a field would be an unhandled rejection in every app that never
14866
- * purges.
16551
+ * Awaited at the top of every purge and of the legacy-pool probe rather than
16552
+ * in the constructor: an engine is built before anybody has asked it for
16553
+ * anything, and a rejected promise kept on a field would be an unhandled
16554
+ * rejection in every app that never reaches the root at all.
14867
16555
  */
14868
16556
  async ensureRoot() {
14869
16557
  if (this.internals.opfsRoot !== void 0 || this.resolvedRoot) return;
@@ -14877,6 +16565,26 @@ var OpfsEngine = class {
14877
16565
  );
14878
16566
  }
14879
16567
  }
16568
+ /**
16569
+ * Which of `pools` still has a directory in the OPFS root (#3760).
16570
+ *
16571
+ * A ledger row for a pool whose directory is gone says nothing anybody can
16572
+ * act on — there is no source left to take a write from — so it does not keep
16573
+ * the scope store's directory alive. With no root at all nothing names any
16574
+ * of them either: `removePoolDirectory` cannot remove a directory it has no
16575
+ * root for, so the ledger is in no danger and this answers none.
16576
+ */
16577
+ async legacyPoolsStillOnDisk(pools) {
16578
+ if (pools.length === 0) return [];
16579
+ const root = this.opfsRoot();
16580
+ if (!root) return [];
16581
+ const present = [];
16582
+ for (const pool of pools) {
16583
+ const directory = format2PoolDirectory(pool);
16584
+ if (await this.directoryExists(root, directory)) present.push(directory);
16585
+ }
16586
+ return present;
16587
+ }
14880
16588
  /** Whether `name` is a directory of the root, without creating it. */
14881
16589
  async directoryExists(root, name) {
14882
16590
  try {
@@ -15109,18 +16817,20 @@ var OpfsEngine = class {
15109
16817
  await this.mirror.close();
15110
16818
  }
15111
16819
  /**
15112
- * The attachment for `docId`'s namespace, joined if this tab has not joined
15113
- * it yet.
16820
+ * The attachment for one scope's namespace, joined if this tab has not
16821
+ * joined it yet.
15114
16822
  *
15115
16823
  * One attachment per namespace, whatever role this tab ends up in: a
15116
16824
  * document is routed to the attachment, not to a worker, because a leader
15117
- * change replaces the worker under it and the rows do not move.
16825
+ * change replaces the worker under it and the rows do not move. Since
16826
+ * #3760 a namespace is a SCOPE, so every large document of one user shares
16827
+ * this entry — and because the attachment re-serves every session on each
16828
+ * new generation, a leader change hands over all of them at once.
15118
16829
  */
15119
- ensureWorker(docId, scope) {
16830
+ ensureWorker(namespace) {
15120
16831
  if (this.destroyed) {
15121
16832
  return Promise.reject(new Error("the opfs engine was destroyed"));
15122
16833
  }
15123
- const namespace = format2StoreNamespace(docId, scope ?? {});
15124
16834
  const existing = this.workers.get(namespace.poolName);
15125
16835
  if (existing && !existing.attachment.died) return Promise.resolve(existing);
15126
16836
  const pending = this.opening.get(namespace.poolName);
@@ -15130,7 +16840,6 @@ var OpfsEngine = class {
15130
16840
  namespace,
15131
16841
  clientId: this.tabId,
15132
16842
  openRequest: {
15133
- docId,
15134
16843
  poolName: namespace.poolName,
15135
16844
  dbFile: namespace.dbFile,
15136
16845
  poolLockName: namespace.poolLockName,
@@ -15408,12 +17117,13 @@ init_BaseModel();
15408
17117
  init_OrmContext();
15409
17118
 
15410
17119
  // src/models/format2DocumentBinding.ts
15411
- var Y8 = __toESM(require("yjs"), 1);
17120
+ var Y9 = __toESM(require("yjs"), 1);
15412
17121
  init_BaseModel();
15413
17122
  init_OrmContext();
15414
17123
  init_format2RecordStoreAsync();
15415
17124
  init_format2StoreHost();
15416
17125
  init_format2Observer();
17126
+ init_format2OverlayKeysSince();
15417
17127
 
15418
17128
  // src/models/format2PendingRestore.ts
15419
17129
  init_largeDocuments();
@@ -15617,7 +17327,14 @@ function sameOverlayValue(a, b) {
15617
17327
  return JSON.stringify(a) === JSON.stringify(b);
15618
17328
  }
15619
17329
  function decideDeferredReplay(note, epoch, ackedSeq) {
15620
- if (!note || note.kind !== "ordinary") return { kind: "none" };
17330
+ if (!note) return { kind: "none" };
17331
+ if (note.kind === "legacy") {
17332
+ if (note.throughSeq <= ackedSeq) {
17333
+ return { kind: "clear", reason: "acknowledged", note };
17334
+ }
17335
+ return { kind: "honour", note, clamped: false };
17336
+ }
17337
+ if (note.kind !== "ordinary") return { kind: "none" };
15621
17338
  let resolved = note;
15622
17339
  let clamped = false;
15623
17340
  if (note.toEpoch > epoch) {
@@ -15969,8 +17686,12 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
15969
17686
  * first one and answer out of a database it has nothing to do with.
15970
17687
  */
15971
17688
  opened = /* @__PURE__ */ new Map();
15972
- /** Serialized query-projection work for folded overlay updates. */
15973
- queue = Promise.resolve();
17689
+ /**
17690
+ * Models a fold batch touched, per document, awaiting their one
17691
+ * notification (#3782). A batch reports its records one by one; the
17692
+ * listeners hear once per model per batch, after the projection landed.
17693
+ */
17694
+ foldedModels = /* @__PURE__ */ new Map();
15974
17695
  /**
15975
17696
  * Refuse a large document this device cannot keep, before anything is wired
15976
17697
  * to it (#2816, sponsor decision B of 2026-08-27).
@@ -16035,19 +17756,16 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16035
17756
  if (Number.isInteger(epoch) && epoch > store.epoch()) {
16036
17757
  store.setEpoch(epoch);
16037
17758
  }
17759
+ store.trackOverlay(yDoc);
16038
17760
  this.unobservers.set(
16039
17761
  docId,
16040
17762
  observeFormat2Overlay(
16041
17763
  yDoc,
16042
17764
  store,
16043
- (modelName, recordId) => this.enqueueProjection(docId, modelName, recordId)
17765
+ (modelName) => this.noteFolded(docId, modelName)
16044
17766
  )
16045
17767
  );
16046
- foldFormat2Overlay(
16047
- yDoc,
16048
- store,
16049
- (modelName, recordId) => this.enqueueProjection(docId, modelName, recordId)
16050
- );
17768
+ this.catchUp(docId, yDoc, store);
16051
17769
  await store.whenBatchesSettled();
16052
17770
  if (options.restorePending !== false) {
16053
17771
  const verdicts = await this.adoptOrphanedWrites(docId, yDoc, store);
@@ -16071,11 +17789,7 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16071
17789
  }
16072
17790
  const adoptedWasRestored = verdicts !== void 0 && restored.restored.some((seq) => verdicts.has(seq));
16073
17791
  if (adoptedWasRestored) {
16074
- foldFormat2Overlay(
16075
- yDoc,
16076
- store,
16077
- (modelName, recordId) => this.enqueueProjection(docId, modelName, recordId)
16078
- );
17792
+ this.catchUp(docId, yDoc, store);
16079
17793
  await store.whenBatchesSettled();
16080
17794
  }
16081
17795
  if (restored.unreproducible.length > 0) {
@@ -16092,6 +17806,42 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16092
17806
  store.onReattached(() => this.revalidateQueryTables(docId, store))
16093
17807
  );
16094
17808
  }
17809
+ /**
17810
+ * Fold what `yDoc` holds that the store does not (#3782).
17811
+ *
17812
+ * - Nothing is known about what the store holds (no folded state), or the
17813
+ * facade is fold-broken: the WHOLE overlay, as every bind did before —
17814
+ * for a broken fold that is the repair.
17815
+ * - Otherwise the items above the stored vector name the keys that moved;
17816
+ * those are folded as one ordinary batch (materialized and completed by
17817
+ * the observer's own path), with any hydrated model the stored state
17818
+ * never covered folded whole. A document that did not move is sent
17819
+ * nothing at all.
17820
+ *
17821
+ * Either way the batch is a bind's: a document behind the store is folded
17822
+ * forced rather than held, because a bind resolves with what local
17823
+ * persistence gave it.
17824
+ */
17825
+ catchUp(docId, yDoc, store) {
17826
+ const onFolded = (modelName) => this.noteFolded(docId, modelName);
17827
+ const stored = store.foldedState();
17828
+ if (stored === null || store.health() === "fold-broken") {
17829
+ foldFormat2Overlay(yDoc, store, onFolded, { atBind: true });
17830
+ return;
17831
+ }
17832
+ const diff = overlayKeysSince(yDoc, stored, (model) => store.isHydrated(model));
17833
+ if (!diff.moved) {
17834
+ Logger.debug(
17835
+ `[format2] ${docId}: the overlay is unchanged since the last fold; nothing to fold.`
17836
+ );
17837
+ return;
17838
+ }
17839
+ const keys = new Map(diff.keys);
17840
+ for (const model of diff.uncovered) {
17841
+ keys.set(model, new Set(yDoc.getMap(model).keys()));
17842
+ }
17843
+ store.enqueueOverlayBatch(yDoc, keys, onFolded, { atBind: true });
17844
+ }
16095
17845
  /**
16096
17846
  * Settle what a deferred judgement means for THIS bind (#3755).
16097
17847
  *
@@ -16139,10 +17889,10 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16139
17889
  *
16140
17890
  * Ordinarily nothing: {@link hydrateQueryTables} skips every model whose
16141
17891
  * projection mark is current, so a re-attachment that lost nothing costs one
16142
- * mark read per model. When a sibling DID wipe — it counted the sessions of
16143
- * a worker this tab had not reached yet and read itself as the last holder —
16144
- * the marks are gone with the rows and the tables are rebuilt from the
16145
- * merged view, which no wipe touches.
17892
+ * mark read per model. A close removes nothing (#3756), but a PURGE can run
17893
+ * while this tab is frozen — it counted the sessions of a worker this tab
17894
+ * had not reached yet — and then the marks are gone with the rows and the
17895
+ * tables are rebuilt from the merged view.
16146
17896
  *
16147
17897
  * Serialized per document and never awaited by the engine: the re-attachment
16148
17898
  * is a notification, not a command, and a rebuild that takes a while must not
@@ -16185,13 +17935,9 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16185
17935
  `[DocumentManager] Cannot apply a sealed overlay to ${docId}: it has no merged record view`
16186
17936
  );
16187
17937
  }
16188
- const doc = new Y8.Doc();
16189
- Y8.applyUpdate(doc, update);
16190
- foldFormat2Overlay(
16191
- doc,
16192
- store,
16193
- (modelName, recordId) => this.enqueueProjection(docId, modelName, recordId)
16194
- );
17938
+ const doc = new Y9.Doc();
17939
+ Y9.applyUpdate(doc, update);
17940
+ foldFormat2Overlay(doc, store, (modelName) => this.noteFolded(docId, modelName));
16195
17941
  await store.whenBatchesSettled();
16196
17942
  doc.destroy();
16197
17943
  await this.whenProjectionSettled();
@@ -16217,11 +17963,7 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16217
17963
  `[DocumentManager] Cannot re-fold the overlay of ${docId}: it has no merged record view`
16218
17964
  );
16219
17965
  }
16220
- foldFormat2Overlay(
16221
- yDoc,
16222
- store,
16223
- (modelName, recordId) => this.enqueueProjection(docId, modelName, recordId)
16224
- );
17966
+ foldFormat2Overlay(yDoc, store, (modelName) => this.noteFolded(docId, modelName));
16225
17967
  await this.whenProjectionSettled();
16226
17968
  }
16227
17969
  /**
@@ -16374,7 +18116,10 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16374
18116
  );
16375
18117
  return;
16376
18118
  }
16377
- await this.hydrateQueryTables(docId, store, { only: modelName });
18119
+ await this.hydrateQueryTables(docId, store, {
18120
+ only: modelName,
18121
+ reason: "registered on a running instance"
18122
+ });
16378
18123
  await this.whenProjectionSettled();
16379
18124
  }
16380
18125
  localModels(docId) {
@@ -16421,28 +18166,54 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16421
18166
  /**
16422
18167
  * Release `docId`. Safe on a document that was never format 2.
16423
18168
  *
16424
- * Asynchronous since #3429: the facade may have operations in flight, and
16425
- * closing under them would reject a save the app is still awaiting. The
16426
- * facade's `close()` drains first.
18169
+ * A release removes nothing (#3756): the records, the unacknowledged
18170
+ * writes, the query tables and their projection marks all stay, so the next
18171
+ * open projects nothing — exactly as a reload that never called close. The
18172
+ * store purge behind the eviction doors is what removes them.
18173
+ *
18174
+ * A kept mark must vouch only for rows that LANDED, so the order matters
18175
+ * (decision D4):
18176
+ *
18177
+ * 1. stop listening, so no NEW fold is queued;
18178
+ * 2. settle the local writes already admitted — commit and projection — and
18179
+ * the folds already queued on the facade, while the facade is
18180
+ * still registered — a fold notifies its records after its
18181
+ * `applyRemoteBatch`, and a projection enqueued against an unregistered
18182
+ * document is dropped silently;
18183
+ * 3. settle the projection queue those folds fed. A projection that fails
18184
+ * here withdraws its model's mark through the still-open facade, and the
18185
+ * next bind rebuilds that model;
18186
+ * 4. only then unregister the facade and close it (its close drains what
18187
+ * the app still has in flight).
16427
18188
  */
16428
- async disconnect(docId, options = {}) {
18189
+ async disconnect(docId) {
16429
18190
  this.unobservers.get(docId)?.();
16430
18191
  this.unobservers.delete(docId);
16431
18192
  this.reattachSubscriptions.get(docId)?.();
16432
18193
  this.reattachSubscriptions.delete(docId);
16433
18194
  const store = this.opened.get(docId);
18195
+ if (!store) return;
18196
+ try {
18197
+ do {
18198
+ await store.whenLocalWritesSettled();
18199
+ await store.whenBatchesSettled();
18200
+ } while (store.localWritesInFlight() > 0);
18201
+ await store.flushLocalStamp();
18202
+ } catch (error) {
18203
+ Logger.error(
18204
+ `[DocumentManager] ${docId}: settling its queued folds before the release failed:`,
18205
+ error
18206
+ );
18207
+ }
18208
+ store.refuseLocalWrites();
16434
18209
  this.opened.delete(docId);
16435
18210
  if (this.ormContext.format2Documents.get(docId) === store) {
16436
18211
  this.ormContext.format2Documents.delete(docId);
16437
18212
  }
16438
- if (!store) return { wiped: false };
16439
- const result = await store.close(
16440
- options.wipeIfLastModels ? { wipeIfLast: { models: options.wipeIfLastModels } } : {}
16441
- );
18213
+ await store.close();
16442
18214
  Logger.info(
16443
- `[DocumentManager] ${docId}: released its merged view; ` + (options.wipeIfLastModels === void 0 ? "no query-table wipe was asked for." : result.wiped ? "it was the last holder, so its query tables were emptied." : "another holder remains, so its query tables were kept.")
18215
+ `[DocumentManager] ${docId}: released its merged view; its query tables and projection marks are kept for the next open.`
16444
18216
  );
16445
- return result;
16446
18217
  }
16447
18218
  /**
16448
18219
  * Take over the unacknowledged writes of clients that are gone, and decide
@@ -16511,28 +18282,6 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16511
18282
  `[DocumentManager] ${docId}: adopted write #${op.seq} (${where}, from client ${op.fromClientId} seq ${op.fromSeq}) is restored in part: ${kept.join(", ")} are still owed, and its other keys were overwritten after it.`
16512
18283
  );
16513
18284
  }
16514
- /**
16515
- * Remove `docId` from `models`' query tables, in THIS thread.
16516
- *
16517
- * The in-process host's `wipeIfLast` hook. Exactly the two steps
16518
- * `removeDocumentData` used to take for a large document, moved here so the
16519
- * observable result is the same wherever the store runs.
16520
- */
16521
- async wipeQueryTables(docId, models) {
16522
- const classes = this.modelRegistry.getActiveModels();
16523
- for (const modelName of models) {
16524
- const modelClass = classes.get(modelName);
16525
- try {
16526
- const engine = typeof modelClass?.queryEngineFor === "function" && modelClass?.dbInstance ? await modelClass.queryEngineFor([docId], modelName) : modelClass?.dbInstance ?? this.dbEngine;
16527
- await engine.deleteByDocumentId(modelName, docId);
16528
- } catch (error) {
16529
- Logger.error(
16530
- `[DocumentManager] ${docId}: could not remove '${modelName}' rows:`,
16531
- error
16532
- );
16533
- }
16534
- }
16535
- }
16536
18285
  /**
16537
18286
  * Close every open document. Used by `destroy()` and `resetJsBao()`.
16538
18287
  *
@@ -16572,7 +18321,6 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16572
18321
  for (const store of Array.from(this.opened.values())) {
16573
18322
  await store.whenBatchesSettled();
16574
18323
  }
16575
- await this.queue;
16576
18324
  }
16577
18325
  /**
16578
18326
  * Rebuild the derived query tables from the merged view.
@@ -16639,56 +18387,67 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16639
18387
  continue;
16640
18388
  }
16641
18389
  await store.forgetQueryProjection(modelName);
18390
+ const started = Date.now();
18391
+ let rows = 0;
16642
18392
  let after = null;
16643
18393
  for (; ; ) {
16644
- const page = await store.readRowsAfter(
18394
+ const page = await store.recordIdsAfter(
16645
18395
  modelName,
16646
18396
  after,
16647
18397
  QUERY_HYDRATION_PAGE_ROWS
16648
18398
  );
16649
18399
  if (page.length === 0) break;
16650
- await modelClass.projectFormat2Records(
16651
- docId,
16652
- page.map((row) => String(row.id))
16653
- );
16654
- after = String(page[page.length - 1].id);
18400
+ await modelClass.projectFormat2Records(docId, page);
18401
+ rows += page.length;
18402
+ after = page[page.length - 1];
16655
18403
  if (page.length < QUERY_HYDRATION_PAGE_ROWS) break;
16656
18404
  }
16657
18405
  await store.markQueryProjectionCurrent(modelName);
18406
+ Logger.info(
18407
+ `[format2] ${docId}: rebuilt '${modelName}' query tables from ${rows} rows in ${Date.now() - started} ms (${forced ? scope.reason ?? "replaced by a snapshot load" : "no projection mark"})`
18408
+ );
16658
18409
  }
16659
18410
  }
16660
18411
  /**
16661
- * Refresh the per-model query projection for a record an overlay update
16662
- * touched. The merged view is already up to date (the fold is synchronous);
16663
- * this keeps `query()` / `aggregate()` in step, serialized on one chain so
16664
- * concurrent updates to the same record cannot interleave.
16665
- *
16666
- * A failure here cannot be swallowed. The mark this model carries says its
16667
- * query tables hold the merged view, and every later session reads that mark
16668
- * and SKIPS the rebuild — so a projection that failed would otherwise leave
16669
- * `query()` answering from a row `find()` disagrees with, for the life of
16670
- * the database. Dropping the mark costs one rebuild at the next bind, which
16671
- * is the repair.
18412
+ * A fold batch landed records of `modelName` — rows AND query rows, since
18413
+ * the projection rides in the batch's own command (#3782). The listeners
18414
+ * hear once per model per batch: the batch reports its records in one
18415
+ * synchronous pass, and the notification is taken a microtask later.
16672
18416
  */
16673
- enqueueProjection(docId, modelName, recordId) {
16674
- const modelClass = this.modelRegistry.getModelClass(modelName);
16675
- if (!modelClass) return;
16676
- const store = this.ormContext.getFormat2StoreAsync(docId);
16677
- if (!store?.isHydrated(modelName)) return;
16678
- this.queue = this.queue.then(() => modelClass.projectFormat2Record(docId, recordId)).catch(async (error) => {
16679
- Logger.error(
16680
- `[DocumentManager] Failed to project ${modelName}/${recordId} into the query tables:`,
16681
- error
16682
- );
16683
- try {
16684
- await store.forgetQueryProjection(modelName);
16685
- } catch (markError) {
16686
- Logger.error(
16687
- `[DocumentManager] Failed to withdraw the query-projection mark of ${modelName}:`,
16688
- markError
16689
- );
16690
- }
16691
- });
18417
+ noteFolded(docId, modelName) {
18418
+ let models = this.foldedModels.get(docId);
18419
+ if (!models) {
18420
+ models = /* @__PURE__ */ new Set();
18421
+ this.foldedModels.set(docId, models);
18422
+ queueMicrotask(() => {
18423
+ const touched = this.foldedModels.get(docId);
18424
+ this.foldedModels.delete(docId);
18425
+ for (const model of touched ?? []) {
18426
+ const modelClass = this.modelRegistry.getModelClass(model);
18427
+ modelClass?.notifyFormat2Folded();
18428
+ }
18429
+ });
18430
+ }
18431
+ models.add(modelName);
18432
+ }
18433
+ /**
18434
+ * Which query tables a fold of `docId` projects into (#3782): one spec per
18435
+ * model with an active class, held on this device, while this binding's
18436
+ * facade is the one registered for the document. A released facade projects
18437
+ * nothing, as a projection enqueued against an unregistered document never
18438
+ * did.
18439
+ */
18440
+ projectionSpecs(docId, store) {
18441
+ return (models) => {
18442
+ if (this.ormContext.format2Documents.get(docId) !== store) return [];
18443
+ const specs = [];
18444
+ for (const model of models) {
18445
+ if (!store.isHydrated(model)) continue;
18446
+ const spec = this.modelRegistry.getModelClass(model)?.format2ProjectionSpec(docId);
18447
+ if (spec) specs.push(spec);
18448
+ }
18449
+ return specs;
18450
+ };
16692
18451
  }
16693
18452
  /**
16694
18453
  * What this device can hold a large document in — the PLATFORM's quota and
@@ -16730,6 +18489,7 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16730
18489
  }
16731
18490
  const host = await this.createHost(docId);
16732
18491
  const store = await Format2RecordStoreAsync.open(host, docId);
18492
+ store.setProjectionProvider(this.projectionSpecs(docId, store));
16733
18493
  this.opened.set(docId, store);
16734
18494
  this.ormContext.format2Documents.set(docId, store);
16735
18495
  return store;
@@ -16751,17 +18511,12 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
16751
18511
  `[DocumentManager] ${docId} is a large document on a store that does not survive this process (${this.dbEngine.constructor.name}): its records and its unacknowledged writes are lost on restart, and a base snapshot cannot be loaded into it.`
16752
18512
  );
16753
18513
  }
16754
- const host = new InProcessFormat2StoreHost(
18514
+ return new InProcessFormat2StoreHost(
16755
18515
  this.dbEngine.createFormat2Sql(),
16756
18516
  docId,
16757
18517
  void 0,
16758
18518
  this.ormContext.format2ClientId
16759
18519
  );
16760
- host.onWipe(async (models) => {
16761
- await this.wipeQueryTables(docId, models);
16762
- await host.dispatch({ kind: "projectionMark", op: "forget", docId });
16763
- });
16764
- return host;
16765
18520
  }
16766
18521
  };
16767
18522
  function withinBound3(promise, boundMs) {
@@ -17241,14 +18996,10 @@ var DocumentManagerImpl = class {
17241
18996
  }
17242
18997
  Logger.info(`[DocumentManager] Disconnecting document ${docId}`);
17243
18998
  const large = this.ormContext.getFormat2StoreAsync(docId) !== null;
17244
- const wipeModels = large ? Array.from(this.modelRegistry.getActiveModels().keys()) : void 0;
17245
18999
  await this.modelRegistry.removeDocumentData(docId, this.dbEngine, {
17246
19000
  skipQueryTableWipe: large
17247
19001
  });
17248
- await this.format2.disconnect(
17249
- docId,
17250
- wipeModels ? { wipeIfLastModels: wipeModels } : {}
17251
- );
19002
+ await this.format2.disconnect(docId);
17252
19003
  this.connectedDocuments.delete(docId);
17253
19004
  for (const docIds of this.runtimeInitialized.values()) {
17254
19005
  docIds.delete(docId);
@@ -17745,7 +19496,7 @@ function attachAndRegisterModel(modelClass, schema) {
17745
19496
  }
17746
19497
 
17747
19498
  // src/utils/yDocSchema.ts
17748
- var Y9 = __toESM(require("yjs"), 1);
19499
+ var Y10 = __toESM(require("yjs"), 1);
17749
19500
  init_yjsValues();
17750
19501
  function discoverSchema(yDoc) {
17751
19502
  const models = {};
@@ -17780,7 +19531,7 @@ function discoverModelNames(yDoc) {
17780
19531
  function materializeMap(yDoc, key) {
17781
19532
  try {
17782
19533
  const map = yDoc.getMap(key);
17783
- return map instanceof Y9.Map ? map : null;
19534
+ return map instanceof Y10.Map ? map : null;
17784
19535
  } catch {
17785
19536
  return null;
17786
19537
  }
@@ -17790,11 +19541,11 @@ function readModelMeta(metaMap) {
17790
19541
  let constraints;
17791
19542
  let relationships;
17792
19543
  for (const [key, value] of metaMap.entries()) {
17793
- if (key === "_constraints" && value instanceof Y9.Map) {
19544
+ if (key === "_constraints" && value instanceof Y10.Map) {
17794
19545
  constraints = readConstraints(value);
17795
- } else if (key === "_relationships" && value instanceof Y9.Map) {
19546
+ } else if (key === "_relationships" && value instanceof Y10.Map) {
17796
19547
  relationships = readRelationships(value);
17797
- } else if (value instanceof Y9.Map) {
19548
+ } else if (value instanceof Y10.Map) {
17798
19549
  fields[key] = readFieldMeta(value);
17799
19550
  }
17800
19551
  }
@@ -17829,7 +19580,7 @@ function inferModelFromData(dataMap) {
17829
19580
  const fields = {};
17830
19581
  let sampled = 0;
17831
19582
  for (const [_recordId, recordValue] of dataMap.entries()) {
17832
- if (!(recordValue instanceof Y9.Map)) continue;
19583
+ if (!(recordValue instanceof Y10.Map)) continue;
17833
19584
  if (++sampled > 5) break;
17834
19585
  for (const [fieldName, value] of recordValue.entries()) {
17835
19586
  if (fieldName.startsWith("_")) continue;
@@ -17857,7 +19608,7 @@ function inferTypeFromValue(value) {
17857
19608
  function readConstraints(constraintsMap) {
17858
19609
  const out = {};
17859
19610
  for (const [name, value] of constraintsMap.entries()) {
17860
- if (!(value instanceof Y9.Map)) continue;
19611
+ if (!(value instanceof Y10.Map)) continue;
17861
19612
  let fields = [];
17862
19613
  const rawFields = value.get("fields");
17863
19614
  if (typeof rawFields === "string") {
@@ -17876,7 +19627,7 @@ function readConstraints(constraintsMap) {
17876
19627
  function readRelationships(relsMap) {
17877
19628
  const out = {};
17878
19629
  for (const [name, value] of relsMap.entries()) {
17879
- if (!(value instanceof Y9.Map)) continue;
19630
+ if (!(value instanceof Y10.Map)) continue;
17880
19631
  const rel = {};
17881
19632
  for (const [k, v] of value.entries()) {
17882
19633
  rel[k] = v;
@@ -18344,7 +20095,7 @@ function suppressSupersededKeys(carried, later) {
18344
20095
  }
18345
20096
 
18346
20097
  // src/models/format2EpochRotation.ts
18347
- var Y10 = __toESM(require("yjs"), 1);
20098
+ var Y11 = __toESM(require("yjs"), 1);
18348
20099
  init_largeDocuments();
18349
20100
  async function followEpoch(host, nextEpoch, options = {}) {
18350
20101
  const sealed = host.overlayDoc();
@@ -18385,7 +20136,7 @@ async function followEpoch(host, nextEpoch, options = {}) {
18385
20136
  suppressDeletes: owed.suppressDeletes
18386
20137
  });
18387
20138
  }
18388
- const fresh = new Y10.Doc();
20139
+ const fresh = new Y11.Doc();
18389
20140
  fresh.documentId = sealed.documentId;
18390
20141
  if (carried.length > 0) {
18391
20142
  fresh.transact(() => {
@@ -18449,7 +20200,7 @@ async function writeDeferredReplay(host, ops, owedOverlay, resolveReplay) {
18449
20200
  }
18450
20201
 
18451
20202
  // src/models/format2FastForward.ts
18452
- var Y11 = __toESM(require("yjs"), 1);
20203
+ var Y12 = __toESM(require("yjs"), 1);
18453
20204
  function planFastForward(options) {
18454
20205
  const { current, target } = options;
18455
20206
  if (!Number.isInteger(target) || target <= 0) {
@@ -18513,8 +20264,8 @@ async function applyFastForward(plan, host) {
18513
20264
  for (const [index, step] of plan.steps.entries()) {
18514
20265
  try {
18515
20266
  const update = await host.fetchArchive(step);
18516
- const doc = new Y11.Doc();
18517
- Y11.applyUpdate(doc, update);
20267
+ const doc = new Y12.Doc();
20268
+ Y12.applyUpdate(doc, update);
18518
20269
  await host.applyOverlay(doc, step.epoch);
18519
20270
  doc.destroy();
18520
20271
  } catch (error) {
@@ -18658,8 +20409,12 @@ function meets(coverage, range) {
18658
20409
  return true;
18659
20410
  }
18660
20411
 
20412
+ // src/browser.ts
20413
+ init_format2FoldedState();
20414
+ init_format2OverlayKeysSince();
20415
+
18661
20416
  // src/models/format2OfflineReplay.ts
18662
- var Y12 = __toESM(require("yjs"), 1);
20417
+ var Y13 = __toESM(require("yjs"), 1);
18663
20418
  init_largeDocuments();
18664
20419
  init_format2Observer();
18665
20420
  var recordKey = (model, recordId) => `${model}\0${recordId}`;
@@ -18701,7 +20456,7 @@ var OfflineConflictLedger = class {
18701
20456
  for (const modelName of Array.from(doc.share.keys())) {
18702
20457
  if (!isFormat2ModelMap(modelName)) continue;
18703
20458
  const modelMap = doc.getMap(modelName);
18704
- if (!(modelMap instanceof Y12.Map) || modelMap.size === 0) continue;
20459
+ if (!(modelMap instanceof Y13.Map) || modelMap.size === 0) continue;
18705
20460
  const entries = [];
18706
20461
  for (const [key, value] of modelMap.entries()) {
18707
20462
  if (!parseOverlayKey(key)) continue;