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

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
@@ -226,7 +226,30 @@ var init_queryTypes = __esm({
226
226
  });
227
227
 
228
228
  // src/query/CursorManager.ts
229
- var base64Encode, base64Decode, CursorManager;
229
+ function cursorSortAlias(fieldName) {
230
+ return `${CURSOR_SORT_ALIAS_PREFIX}${fieldName}`;
231
+ }
232
+ function cursorMintingRecord(row, aliasedFields) {
233
+ let folded;
234
+ for (const field of aliasedFields) {
235
+ const alias = cursorSortAlias(field);
236
+ if (!Object.prototype.hasOwnProperty.call(row, alias)) continue;
237
+ folded ??= { ...row };
238
+ folded[field] = row[alias];
239
+ }
240
+ return folded ?? row;
241
+ }
242
+ function stripCursorSortValues(row, aliasedFields) {
243
+ let stripped;
244
+ for (const field of aliasedFields) {
245
+ const alias = cursorSortAlias(field);
246
+ if (!Object.prototype.hasOwnProperty.call(row, alias)) continue;
247
+ stripped ??= { ...row };
248
+ delete stripped[alias];
249
+ }
250
+ return stripped ?? row;
251
+ }
252
+ var base64Encode, base64Decode, CURSOR_SORT_ALIAS_PREFIX, CursorManager;
230
253
  var init_CursorManager = __esm({
231
254
  "src/query/CursorManager.ts"() {
232
255
  "use strict";
@@ -249,6 +272,7 @@ var init_CursorManager = __esm({
249
272
  throw new Error("No base64 decoding available");
250
273
  }
251
274
  };
275
+ CURSOR_SORT_ALIAS_PREFIX = "_cursor:";
252
276
  CursorManager = class {
253
277
  /**
254
278
  * Encode cursor data to base64 string
@@ -292,18 +316,22 @@ var init_CursorManager = __esm({
292
316
  }
293
317
  }
294
318
  /**
295
- * Generate cursor from a record and sort specification
319
+ * Generate cursor from a record and sort specification.
320
+ *
321
+ * A sort field the record does not carry — absent, `null`, or `undefined` —
322
+ * is encoded as an explicit JSON `null` (#3228). Absent and null are
323
+ * deliberately NOT distinguished: `json_extract` returns SQL NULL for both an
324
+ * absent path and a stored JSON null, and a per-column table stores an absent
325
+ * field as a NULL column, so the two are one value at the SQL layer on every
326
+ * engine. `null` rather than `undefined` because `JSON.stringify` drops a key
327
+ * whose value is `undefined`, and the payload's shape is the wire contract
328
+ * the Swift port reads.
296
329
  */
297
330
  static generateCursor(record, sortFields, direction) {
298
331
  const values = {};
299
332
  for (const field of sortFields) {
300
- if (record.hasOwnProperty(field)) {
301
- values[field] = record[field];
302
- } else {
303
- throw new Error(
304
- `Cannot generate cursor: record missing sort field '${field}'`
305
- );
306
- }
333
+ const value = Object.prototype.hasOwnProperty.call(record, field) ? record[field] : void 0;
334
+ values[field] = value === void 0 ? null : value;
307
335
  }
308
336
  const cursorData = {
309
337
  values,
@@ -315,6 +343,28 @@ var init_CursorManager = __esm({
315
343
  /**
316
344
  * Build SQL pagination conditions based on cursor
317
345
  * Uses lexicographic ordering for stable pagination with multiple sort fields
346
+ *
347
+ * NULL-aware (#3228). SQLite orders NULL before every value, so absent and
348
+ * null sort values sit at the FRONT of an ascending output order and at the
349
+ * TAIL of a descending one — on the Cloudflare DO's SQLite, better-sqlite3
350
+ * and the Swift client's SQLite alike. That is the ratified ordering
351
+ * contract, which is why no ORDER BY changes here; what changes is that a
352
+ * cursor value of null no longer compiles to a comparison against a NULL
353
+ * bound parameter, which SQLite evaluates as UNKNOWN so the next page matched
354
+ * nothing. Per level, with the effective operator computed exactly as before:
355
+ *
356
+ * - equality level, null value → `field IS NULL` (no parameter)
357
+ * - `>` (nulls at the front), value → `field > ?` (unchanged)
358
+ * - `>` (nulls at the front), null → `field IS NOT NULL` (no parameter)
359
+ * - `<` (nulls at the tail), value → `(field < ? OR field IS NULL)`
360
+ * - `<` (nulls at the tail), null → the level matches nothing and is
361
+ * dropped; the deeper tiebreak levels, whose equality parts are
362
+ * `field IS NULL`, carry the walk through the null run.
363
+ *
364
+ * `id` is exempt: it is a non-null primary key on every engine, so it never
365
+ * receives `IS NULL` handling and the default `sort: {id}` pagination SQL
366
+ * stays byte-identical to what it was. So does every non-null `>` level, so
367
+ * an ascending page over an old-format cursor compiles exactly as before.
318
368
  */
319
369
  static buildPaginationConditions(cursor, currentSortFields, sortDirections, requestedDirection, fieldFormatter = (field) => field) {
320
370
  if (!this.arraysEqual(cursor.sortFields, currentSortFields)) {
@@ -329,27 +379,46 @@ var init_CursorManager = __esm({
329
379
  const params = [];
330
380
  const sortFields = cursor.sortFields;
331
381
  const direction = requestedDirection;
382
+ const nullable = (field) => field !== "id";
383
+ const isNull = (field) => nullable(field) && (cursor.values[field] === null || cursor.values[field] === void 0);
332
384
  for (let i = 0; i < sortFields.length; i++) {
333
385
  const fieldConditions = [];
334
386
  const fieldParams = [];
335
387
  for (let j = 0; j < i; j++) {
336
388
  const field = sortFields[j];
337
- const value = cursor.values[field];
389
+ if (isNull(field)) {
390
+ fieldConditions.push(`${fieldFormatter(field)} IS NULL`);
391
+ continue;
392
+ }
338
393
  fieldConditions.push(`${fieldFormatter(field)} = ?`);
339
- fieldParams.push(value);
394
+ fieldParams.push(cursor.values[field]);
340
395
  }
341
396
  const currentField = sortFields[i];
342
397
  const currentValue = cursor.values[currentField];
398
+ const currentSql = fieldFormatter(currentField);
343
399
  const fieldSortDir = sortDirections[i] ?? 1;
344
400
  const forwardOp = fieldSortDir === 1 ? ">" : "<";
345
401
  const operator = direction === 1 ? forwardOp : forwardOp === ">" ? "<" : ">";
346
- fieldConditions.push(`${fieldFormatter(currentField)} ${operator} ?`);
347
- fieldParams.push(currentValue);
402
+ if (isNull(currentField)) {
403
+ if (operator === "<") {
404
+ continue;
405
+ }
406
+ fieldConditions.push(`${currentSql} IS NOT NULL`);
407
+ } else if (operator === "<" && nullable(currentField)) {
408
+ const disjunction = `${currentSql} < ? OR ${currentSql} IS NULL`;
409
+ fieldConditions.push(
410
+ fieldConditions.length > 0 ? `(${disjunction})` : disjunction
411
+ );
412
+ fieldParams.push(currentValue);
413
+ } else {
414
+ fieldConditions.push(`${currentSql} ${operator} ?`);
415
+ fieldParams.push(currentValue);
416
+ }
348
417
  const levelCondition = fieldConditions.join(" AND ");
349
418
  conditions.push(`(${levelCondition})`);
350
419
  params.push(...fieldParams);
351
420
  }
352
- const sql = `(${conditions.join(" OR ")})`;
421
+ const sql = conditions.length > 0 ? `(${conditions.join(" OR ")})` : "(1 = 0)";
353
422
  return { sql, params };
354
423
  }
355
424
  /**
@@ -580,7 +649,14 @@ var init_DocumentQueryTranslator = __esm({
580
649
  orderClause.fields,
581
650
  orderClause.directions
582
651
  );
583
- const selectClause = this.buildSelectClause(options?.projection);
652
+ const cursorSortValueFields = this.cursorSortValueFields(
653
+ options?.projection,
654
+ orderClause.fields
655
+ );
656
+ const selectClause = this.buildSelectClause(
657
+ options?.projection,
658
+ cursorSortValueFields
659
+ );
584
660
  let sql = `SELECT ${selectClause} FROM ${this.quotedTableName}`;
585
661
  const params = [];
586
662
  const conditions = [];
@@ -610,7 +686,8 @@ var init_DocumentQueryTranslator = __esm({
610
686
  sql,
611
687
  params,
612
688
  sortFields: orderClause.fields,
613
- sortDirections: orderClause.directions
689
+ sortDirections: orderClause.directions,
690
+ cursorSortValueFields
614
691
  };
615
692
  }
616
693
  /**
@@ -999,10 +1076,36 @@ var init_DocumentQueryTranslator = __esm({
999
1076
  );
1000
1077
  }
1001
1078
  }
1079
+ /**
1080
+ * The sort fields whose values this query has to select under an internal
1081
+ * alias: the ones an INCLUSION projection omits that have a main-table
1082
+ * column (#3228). Stringset fields have no such column — they are populated
1083
+ * post-query — so they are skipped the way the projection skips them.
1084
+ *
1085
+ * Reported on the `TranslatedQuery` so `BaseModel.query` folds and strips
1086
+ * exactly the columns this SELECT added — never a column the caller asked
1087
+ * for, and never a schema field whose name happens to look like an alias.
1088
+ */
1089
+ cursorSortValueFields(projection, sortFields) {
1090
+ if (!projection || Object.keys(projection).length === 0) return [];
1091
+ const included = new Set(
1092
+ Object.entries(projection).filter(([, include]) => include === 1).map(([field]) => field)
1093
+ );
1094
+ if (included.size === 0) return [];
1095
+ return sortFields.filter(
1096
+ (field) => !SYSTEM_FIELDS.has(field) && !included.has(field) && this.schema.has(field) && this.schema.get(field)?.type !== "stringset"
1097
+ );
1098
+ }
1002
1099
  /**
1003
1100
  * Build SELECT clause based on projection
1101
+ *
1102
+ * `aliasedSortFields` names the sort fields this query carries under an
1103
+ * internal alias because the projection omits them
1104
+ * ({@link cursorSortValueFields}, #3228) so the cursor carries the row's real
1105
+ * value rather than a false null; `BaseModel.query` strips those columns
1106
+ * before the rows leave it, so response shapes are unchanged.
1004
1107
  */
1005
- buildSelectClause(projection) {
1108
+ buildSelectClause(projection, aliasedSortFields) {
1006
1109
  if (!projection || Object.keys(projection).length === 0) {
1007
1110
  return "*";
1008
1111
  }
@@ -1031,6 +1134,13 @@ var init_DocumentQueryTranslator = __esm({
1031
1134
  includeFields.unshift("id");
1032
1135
  }
1033
1136
  const quoted = includeFields.map((field) => this.getQuotedField(field));
1137
+ for (const field of aliasedSortFields ?? []) {
1138
+ quoted.push(
1139
+ `${this.getQuotedField(field)} AS ${quoteIdentifier(
1140
+ cursorSortAlias(field)
1141
+ )}`
1142
+ );
1143
+ }
1034
1144
  return quoted.join(", ");
1035
1145
  } else if (hasExcludes) {
1036
1146
  const allFields = Array.from(this.schema.keys());
@@ -1271,6 +1381,19 @@ function buildUniqueKey(recordData, fields) {
1271
1381
  }
1272
1382
  return keyParts.join("|");
1273
1383
  }
1384
+ function removeUniqueIndexEntriesForRecordId(yDoc, modelName, constraintNames, recordId) {
1385
+ let removed = 0;
1386
+ for (const constraintName of constraintNames) {
1387
+ const indexMap = yDoc.getMap(uniqueIndexMapName(modelName, constraintName));
1388
+ for (const key of Array.from(indexMap.keys())) {
1389
+ if (indexMap.get(key) === recordId) {
1390
+ indexMap.delete(key);
1391
+ removed++;
1392
+ }
1393
+ }
1394
+ }
1395
+ return removed;
1396
+ }
1274
1397
  function uniqueIndexMapName(modelName, constraintName) {
1275
1398
  return `_uniqueIdx_${modelName}_${constraintName}`;
1276
1399
  }
@@ -1803,6 +1926,15 @@ var init_format2OfflineWindow = __esm({
1803
1926
  lastSyncAt;
1804
1927
  windowDays;
1805
1928
  overdueMs;
1929
+ /**
1930
+ * The same four numbers, under the name the Swift client puts them
1931
+ * (`JsBaoError.details`).
1932
+ *
1933
+ * Additive, beside the properties this error has always carried: an app
1934
+ * running both clients reads one shape, and one that only reads the
1935
+ * properties is unaffected.
1936
+ */
1937
+ details;
1806
1938
  constructor(documentId, status) {
1807
1939
  super(
1808
1940
  `Document '${documentId}' has not synced for ${Math.floor((status.windowMs + status.overdueMs) / DAY_MS)} days, past its ${status.windowDays}-day offline write window. It stays readable, and writes are accepted again once it syncs.`
@@ -1812,6 +1944,12 @@ var init_format2OfflineWindow = __esm({
1812
1944
  this.lastSyncAt = status.lastSyncAt;
1813
1945
  this.windowDays = status.windowDays;
1814
1946
  this.overdueMs = status.overdueMs;
1947
+ this.details = {
1948
+ documentId,
1949
+ lastSyncAt: status.lastSyncAt,
1950
+ windowDays: status.windowDays,
1951
+ overdueMs: status.overdueMs
1952
+ };
1815
1953
  }
1816
1954
  };
1817
1955
  }
@@ -2157,6 +2295,12 @@ function statementsFor(tables) {
2157
2295
  const members = tables.stringSetIndex;
2158
2296
  const statements = {
2159
2297
  deleteRecord: `DELETE FROM ${records} WHERE _type = ? AND _id = ?`,
2298
+ // Every create through the record door carries `_replace`, so this runs
2299
+ // once per record. It is served by the member table's `(_type, _record_id,
2300
+ // field)` index — `idx_ss_type_record_field` on the server,
2301
+ // `idx_<members>_trf` on a client (#3688). Without an index whose second
2302
+ // column is `_record_id` the planner narrows on `_type` alone and this
2303
+ // walks every member row of the model, per record.
2160
2304
  deleteAllMembers: `DELETE FROM ${members} WHERE _type = ? AND _record_id = ?`,
2161
2305
  deleteMember: `DELETE FROM ${members} WHERE _type = ? AND _record_id = ? AND field = ? AND value = ?`,
2162
2306
  insertMember: `INSERT OR IGNORE INTO ${members} (_record_id, _type, field, value) VALUES (?, ?, ?, ?)`,
@@ -2259,7 +2403,13 @@ function format2ClientDDL(docId) {
2259
2403
  )`,
2260
2404
  `CREATE INDEX IF NOT EXISTS idx_${records}_type ON ${records}(_type)`,
2261
2405
  `CREATE INDEX IF NOT EXISTS idx_${stringSetIndex}_tfv ON ${stringSetIndex}(_type, field, value)`,
2262
- `CREATE INDEX IF NOT EXISTS idx_${stringSetIndex}_tfr ON ${stringSetIndex}(_type, field, _record_id)`,
2406
+ // `_record_id` DIRECTLY after `_type` (#3688): the shared fold's
2407
+ // per-record member delete (`_replace`, and every tombstone) and the
2408
+ // coverage delete both key on that pair, and the retired
2409
+ // `(_type, field, _record_id)` shape left the planner narrowing on `_type`
2410
+ // alone — a walk of the model's whole member table per record. The old
2411
+ // 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)`,
2263
2413
  `CREATE TABLE IF NOT EXISTS _epoch (
2264
2414
  doc_id TEXT PRIMARY KEY,
2265
2415
  epoch INTEGER NOT NULL DEFAULT 0,
@@ -2307,16 +2457,109 @@ function format2ClientDDL(docId) {
2307
2457
  doc_id TEXT NOT NULL,
2308
2458
  epoch INTEGER NOT NULL,
2309
2459
  PRIMARY KEY (doc_id, epoch)
2310
- )`
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
2311
2474
  ];
2312
2475
  }
2476
+ function toDeferredReplayNote(rows) {
2477
+ if (rows.length === 0) return null;
2478
+ const row = rows[0];
2479
+ return {
2480
+ throughSeq: Number(row.through_seq),
2481
+ fromEpoch: Number(row.from_epoch),
2482
+ toEpoch: Number(row.to_epoch),
2483
+ kind: String(row.kind)
2484
+ };
2485
+ }
2486
+ function format2DocumentIdFromTableName(table) {
2487
+ const prefix = "records_f2_";
2488
+ if (!table.startsWith(prefix)) return null;
2489
+ const hex = table.slice(prefix.length);
2490
+ if (hex.length === 0 || hex.length % 4 !== 0 || !/^[0-9a-f]+$/.test(hex)) {
2491
+ return null;
2492
+ }
2493
+ let docId = "";
2494
+ for (let at = 0; at < hex.length; at += 4) {
2495
+ docId += String.fromCharCode(parseInt(hex.slice(at, at + 4), 16));
2496
+ }
2497
+ return docId;
2498
+ }
2499
+ function listTables(sql) {
2500
+ return Array.from(
2501
+ sql.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
2502
+ ).map((row) => String(row.name));
2503
+ }
2504
+ function tableColumns(sql, table) {
2505
+ try {
2506
+ return Array.from(sql.exec(`PRAGMA table_info(${quote(table)})`)).map(
2507
+ (row) => String(row.name)
2508
+ );
2509
+ } catch {
2510
+ return [];
2511
+ }
2512
+ }
2513
+ function countRows(sql, table, column, value) {
2514
+ const rows = Array.from(
2515
+ sql.exec(
2516
+ `SELECT count(*) AS n FROM ${quote(table)} WHERE ${quote(column)} = ?`,
2517
+ value
2518
+ )
2519
+ );
2520
+ return rows.length > 0 ? Number(rows[0].n ?? 0) : 0;
2521
+ }
2522
+ function junctionForeignKeyOf(parent) {
2523
+ const model = parent.startsWith("model_") ? parent.slice("model_".length) : parent;
2524
+ return `${model}_id`;
2525
+ }
2526
+ function junctionTablesOf(parent, tables, columnsOf) {
2527
+ const found = [];
2528
+ const foreignKey = junctionForeignKeyOf(parent);
2529
+ for (const table of tables) {
2530
+ if (!table.startsWith(`${parent}_`)) continue;
2531
+ const columns = columnsOf.get(table) ?? [];
2532
+ if (!columns.includes("value")) continue;
2533
+ if (columns.includes("_meta_doc_id")) continue;
2534
+ if (!columns.includes(foreignKey)) continue;
2535
+ found.push({ table, foreignKey });
2536
+ }
2537
+ return found;
2538
+ }
2539
+ function countStoredDocuments(sql) {
2540
+ const found = /* @__PURE__ */ new Set();
2541
+ try {
2542
+ for (const row of sql.exec("SELECT doc_id FROM _epoch")) {
2543
+ found.add(String(row.doc_id));
2544
+ }
2545
+ } catch {
2546
+ }
2547
+ for (const table of listTables(sql)) {
2548
+ const docId = format2DocumentIdFromTableName(table);
2549
+ if (docId !== null) found.add(docId);
2550
+ }
2551
+ return Array.from(found);
2552
+ }
2553
+ function quote(identifier) {
2554
+ return `"${identifier.replace(/"/g, '""')}"`;
2555
+ }
2313
2556
  function normalizeBinding(value) {
2314
2557
  if (value === void 0 || value === null) return null;
2315
2558
  if (typeof value === "boolean") return value ? 1 : 0;
2316
2559
  if (typeof value === "number") return value;
2317
2560
  return String(value);
2318
2561
  }
2319
- var FORMAT2_LEGACY_CLIENT_ID, PENDING_OPS_DDL, Format2ModelNotHydratedError, Format2RecordStore;
2562
+ var FORMAT2_LEGACY_CLIENT_ID, PENDING_OPS_DDL, DEFERRED_REPLAY_DDL, Format2ModelNotHydratedError, Format2RecordStore;
2320
2563
  var init_format2RecordStore = __esm({
2321
2564
  "src/models/format2RecordStore.ts"() {
2322
2565
  "use strict";
@@ -2339,6 +2582,15 @@ var init_format2RecordStore = __esm({
2339
2582
  from_client_id TEXT,
2340
2583
  from_seq INTEGER,
2341
2584
  PRIMARY KEY (doc_id, client_id, seq)
2585
+ )`;
2586
+ DEFERRED_REPLAY_DDL = `CREATE TABLE IF NOT EXISTS _deferred_replay (
2587
+ doc_id TEXT NOT NULL,
2588
+ client_id TEXT NOT NULL,
2589
+ through_seq INTEGER NOT NULL,
2590
+ from_epoch INTEGER NOT NULL,
2591
+ to_epoch INTEGER NOT NULL,
2592
+ kind TEXT NOT NULL,
2593
+ PRIMARY KEY (doc_id, client_id)
2342
2594
  )`;
2343
2595
  Format2ModelNotHydratedError = class extends Error {
2344
2596
  constructor(model, hydrated) {
@@ -2352,7 +2604,7 @@ var init_format2RecordStore = __esm({
2352
2604
  /** Stable across builds, so an app can branch on it rather than on text. */
2353
2605
  code = "FORMAT2_MODEL_NOT_HYDRATED";
2354
2606
  };
2355
- Format2RecordStore = class {
2607
+ Format2RecordStore = class _Format2RecordStore {
2356
2608
  constructor(sql, docId, clientId) {
2357
2609
  this.sql = sql;
2358
2610
  this.docId = docId;
@@ -2404,6 +2656,9 @@ var init_format2RecordStore = __esm({
2404
2656
  /** Create the local schema. Safe to call on every open. */
2405
2657
  init() {
2406
2658
  for (const ddl of format2ClientDDL(this.docId)) this.sql.exec(ddl);
2659
+ this.sql.exec(
2660
+ `DROP INDEX IF EXISTS idx_${this.tables.stringSetIndex}_tfr`
2661
+ );
2407
2662
  const columns = Array.from(this.sql.exec("PRAGMA table_info(_epoch)")).map(
2408
2663
  (row) => String(row.name)
2409
2664
  );
@@ -2915,6 +3170,34 @@ var init_format2RecordStore = __esm({
2915
3170
  this.docId,
2916
3171
  ...wanted
2917
3172
  );
3173
+ for (const note of this.rows(
3174
+ `SELECT client_id, through_seq, from_epoch, to_epoch, kind
3175
+ FROM _deferred_replay
3176
+ WHERE doc_id = ? AND client_id IN (${placeholders})`,
3177
+ this.docId,
3178
+ ...wanted
3179
+ )) {
3180
+ const from2 = String(note.client_id);
3181
+ const throughSeq = Number(note.through_seq);
3182
+ let covered = 0;
3183
+ for (const op of adopted) {
3184
+ if (op.fromClientId === from2 && op.fromSeq <= throughSeq) {
3185
+ covered = Math.max(covered, op.seq);
3186
+ }
3187
+ }
3188
+ if (covered === 0) continue;
3189
+ this.noteDeferredReplay({
3190
+ throughSeq: covered,
3191
+ fromEpoch: Number(note.from_epoch),
3192
+ toEpoch: Number(note.to_epoch),
3193
+ kind: String(note.kind)
3194
+ });
3195
+ }
3196
+ this.sql.exec(
3197
+ `DELETE FROM _deferred_replay WHERE doc_id = ? AND client_id IN (${placeholders})`,
3198
+ this.docId,
3199
+ ...wanted
3200
+ );
2918
3201
  return adopted;
2919
3202
  });
2920
3203
  }
@@ -2925,11 +3208,91 @@ var init_format2RecordStore = __esm({
2925
3208
  this.docId
2926
3209
  );
2927
3210
  }
2928
- setEpoch(epoch) {
3211
+ /**
3212
+ * Move the epoch mark, and settle this client's deferred judgement with it
3213
+ * (#3755).
3214
+ *
3215
+ * ONE transaction, which is the Swift contract and the reason the note is
3216
+ * not a second write after the mark: the mark moving without the note is
3217
+ * exactly the state a restart cannot tell from "nothing is owed", and it is
3218
+ * the state this child exists to make unreachable.
3219
+ *
3220
+ * With a note, it is upserted by the widening rule below. WITHOUT one — an
3221
+ * ordinary seal, or the bind's upward bump — an existing note's target is
3222
+ * carried forward to the epoch just joined, so an in-session seal after a
3223
+ * deferral leaves the note current instead of naming an epoch the store has
3224
+ * already passed. A client with no note is untouched.
3225
+ */
3226
+ setEpoch(epoch, options = {}) {
3227
+ this.sql.transaction(() => {
3228
+ this.sql.exec(
3229
+ "UPDATE _epoch SET epoch = ? WHERE doc_id = ?",
3230
+ epoch,
3231
+ this.docId
3232
+ );
3233
+ if (options.deferredReplay) {
3234
+ this.noteDeferredReplay(options.deferredReplay);
3235
+ return;
3236
+ }
3237
+ const clientId = this.clientIdentity();
3238
+ if (clientId === null) return;
3239
+ this.sql.exec(
3240
+ `UPDATE _deferred_replay SET to_epoch = MAX(to_epoch, ?)
3241
+ WHERE doc_id = ? AND client_id = ?`,
3242
+ epoch,
3243
+ this.docId,
3244
+ clientId
3245
+ );
3246
+ });
3247
+ }
3248
+ // ── The deferred judgement (#3755) ────────────────────────────────
3249
+ /**
3250
+ * Record that this client owes a judgement over `note`'s span. Idempotent.
3251
+ *
3252
+ * A second deferral WIDENS the first rather than replacing it (Swift's rule,
3253
+ * `noteDeferredReplay`): `MAX(through_seq)`, `MIN(from_epoch)`,
3254
+ * `MAX(to_epoch)`, so one note covers both debts and neither is forgotten.
3255
+ * `kind` is replaced, because it is not a span.
3256
+ */
3257
+ noteDeferredReplay(note) {
3258
+ this.sql.exec(
3259
+ `INSERT INTO _deferred_replay
3260
+ (doc_id, client_id, through_seq, from_epoch, to_epoch, kind)
3261
+ VALUES (?, ?, ?, ?, ?, ?)
3262
+ ON CONFLICT(doc_id, client_id) DO UPDATE SET
3263
+ through_seq = MAX(through_seq, ?),
3264
+ from_epoch = MIN(from_epoch, ?),
3265
+ to_epoch = MAX(to_epoch, ?),
3266
+ kind = ?`,
3267
+ this.docId,
3268
+ this.client(),
3269
+ note.throughSeq,
3270
+ note.fromEpoch,
3271
+ note.toEpoch,
3272
+ note.kind,
3273
+ note.throughSeq,
3274
+ note.fromEpoch,
3275
+ note.toEpoch,
3276
+ note.kind
3277
+ );
3278
+ }
3279
+ /** The judgement THIS client owes, or `null`. Never a sibling tab's. */
3280
+ deferredReplay() {
3281
+ return toDeferredReplayNote(
3282
+ this.rows(
3283
+ `SELECT through_seq, from_epoch, to_epoch, kind FROM _deferred_replay
3284
+ WHERE doc_id = ? AND client_id = ?`,
3285
+ this.docId,
3286
+ this.client()
3287
+ )
3288
+ );
3289
+ }
3290
+ /** The judgement has been made: this client's note only. */
3291
+ clearDeferredReplay() {
2929
3292
  this.sql.exec(
2930
- "UPDATE _epoch SET epoch = ? WHERE doc_id = ?",
2931
- epoch,
2932
- this.docId
3293
+ "DELETE FROM _deferred_replay WHERE doc_id = ? AND client_id = ?",
3294
+ this.docId,
3295
+ this.client()
2933
3296
  );
2934
3297
  }
2935
3298
  lastSyncAt() {
@@ -3320,6 +3683,143 @@ var init_format2RecordStore = __esm({
3320
3683
  }
3321
3684
  return doomed.length;
3322
3685
  }
3686
+ // ── Purging one document, or all of them (#3754) ──────────────────
3687
+ /**
3688
+ * Remove every trace of `docId` from the database `sql` runs on.
3689
+ *
3690
+ * The Swift client has had this since #3436 (`Format2RecordStore.purge`);
3691
+ * the JS store had nothing, so `documents.evict`,
3692
+ * `closeDocument({ evictLocal })` and `logout({ wipeLocal })` all left a
3693
+ * large document's rows, its unacknowledged writes and its projected query
3694
+ * rows exactly where they were.
3695
+ *
3696
+ * Static rather than an instance method for the same reason Swift's is: a
3697
+ * purge outlives the store it is about. Nothing here needs the document's
3698
+ * client id, its scope or its hydration state, and constructing an instance
3699
+ * would mean running `init()` — creating the very tables this is about to
3700
+ * drop — on a database that may never have held them.
3701
+ *
3702
+ * ### What is discovered, and why it is not a list
3703
+ *
3704
+ * Every table is found by SHAPE:
3705
+ *
3706
+ * - a `_meta_doc_id` column makes a table a DERIVED query table, one row per
3707
+ * record of some document;
3708
+ * - a `doc_id` column makes it one of the shared bookkeeping tables
3709
+ * (`_epoch`, `_pending_ops`, `_client_acks`, `_snapshot_load`,
3710
+ * `_snapshot_base`, `_discontinuities`, `_query_projection`);
3711
+ * - `records_f2_{hex}` and `stringset_index_f2_{hex}` are the document's own.
3712
+ *
3713
+ * A list would have to be kept in step with every sibling that adds a table
3714
+ * — #3755's `_deferred_replay` is one — and the first one forgotten is local
3715
+ * data left behind with a report saying it was removed. Swift discovers the
3716
+ * derived tables by shape for exactly this reason
3717
+ * (`Format2RecordStore.swift:1370-1379`).
3718
+ *
3719
+ * ### The one ordering that matters
3720
+ *
3721
+ * A projected string-set junction table is `{table}_{field}` with columns
3722
+ * `(id, {model}_id, value)` and NO document column
3723
+ * (`sqliteQueryEngine.ts:213-232`): its rows can only be reached through
3724
+ * their parent's ids. So the member rows go FIRST, keyed off a subquery over
3725
+ * the parents, and the parents after. `deleteByDocumentId` has these the
3726
+ * other way round — it deletes the parent rows and then runs a subquery over
3727
+ * the rows it has just deleted, which matches nothing — so this does not use
3728
+ * it (finding 3754-SO-07; #3756 owns that path).
3729
+ */
3730
+ static purge(sql, docId) {
3731
+ return sql.transaction(() => {
3732
+ const tables = listTables(sql);
3733
+ if (!tables.includes("_epoch")) {
3734
+ return { nothingStored: true, pendingOps: 0, remainingDocuments: 0 };
3735
+ }
3736
+ const pendingOps = tables.includes("_pending_ops") ? countRows(sql, "_pending_ops", "doc_id", docId) : 0;
3737
+ const derived = [];
3738
+ const bookkeeping = [];
3739
+ const columnsOf = /* @__PURE__ */ new Map();
3740
+ for (const table of tables) {
3741
+ const columns = tableColumns(sql, table);
3742
+ columnsOf.set(table, columns);
3743
+ if (columns.includes("_meta_doc_id")) derived.push(table);
3744
+ else if (columns.includes("doc_id")) bookkeeping.push(table);
3745
+ }
3746
+ for (const parent of derived) {
3747
+ for (const junction of junctionTablesOf(parent, tables, columnsOf)) {
3748
+ sql.exec(
3749
+ `DELETE FROM ${quote(junction.table)} WHERE ${quote(
3750
+ junction.foreignKey
3751
+ )} IN (SELECT ${quote("id")} FROM ${quote(
3752
+ parent
3753
+ )} WHERE ${quote("_meta_doc_id")} = ?)`,
3754
+ docId
3755
+ );
3756
+ sql.exec(
3757
+ `DELETE FROM ${quote(junction.table)} WHERE ${quote(
3758
+ junction.foreignKey
3759
+ )} NOT IN (SELECT ${quote("id")} FROM ${quote(parent)})`
3760
+ );
3761
+ }
3762
+ }
3763
+ for (const parent of derived) {
3764
+ sql.exec(
3765
+ `DELETE FROM ${quote(parent)} WHERE ${quote("_meta_doc_id")} = ?`,
3766
+ docId
3767
+ );
3768
+ }
3769
+ for (const table of bookkeeping) {
3770
+ sql.exec(`DELETE FROM ${quote(table)} WHERE ${quote("doc_id")} = ?`, docId);
3771
+ }
3772
+ const own = format2TableNames(docId);
3773
+ sql.exec(`DROP TABLE IF EXISTS ${quote(own.records)}`);
3774
+ sql.exec(`DROP TABLE IF EXISTS ${quote(own.stringSetIndex)}`);
3775
+ return {
3776
+ pendingOps,
3777
+ remainingDocuments: countStoredDocuments(sql).length
3778
+ };
3779
+ });
3780
+ }
3781
+ /** `_pending_ops` rows for `docId`, across every client that wrote one. */
3782
+ static pendingOpCount(sql, docId) {
3783
+ try {
3784
+ return countRows(sql, "_pending_ops", "doc_id", docId);
3785
+ } catch {
3786
+ return 0;
3787
+ }
3788
+ }
3789
+ /** Every large document this database holds, from `_epoch` and from names. */
3790
+ static storedDocuments(sql) {
3791
+ return countStoredDocuments(sql);
3792
+ }
3793
+ /**
3794
+ * Purge every document this database holds except `keep` (#3754).
3795
+ *
3796
+ * Unforced, a document whose store still holds unacknowledged writes is
3797
+ * KEPT and reported: a wipe is not a licence to drop a write the app was
3798
+ * told had been saved. Documents are enumerated from `_epoch` AND from the
3799
+ * `records_f2_%` table names, so a store left behind by a build before this
3800
+ * fix — tables with no `_epoch` row — is reached too (finding R3).
3801
+ */
3802
+ static purgeAll(sql, options = {}) {
3803
+ const keep = new Set(options.keep ?? []);
3804
+ const purged = [];
3805
+ const kept = [];
3806
+ for (const docId of countStoredDocuments(sql)) {
3807
+ if (keep.has(docId)) {
3808
+ kept.push({ docId, reason: "keep" });
3809
+ continue;
3810
+ }
3811
+ if (options.force !== true) {
3812
+ const pendingOps = _Format2RecordStore.pendingOpCount(sql, docId);
3813
+ if (pendingOps > 0) {
3814
+ kept.push({ docId, reason: "pending", pendingOps });
3815
+ continue;
3816
+ }
3817
+ }
3818
+ _Format2RecordStore.purge(sql, docId);
3819
+ purged.push(docId);
3820
+ }
3821
+ return { purged, kept };
3822
+ }
3323
3823
  /**
3324
3824
  * Throw away a merged view that cannot be repaired (#2816, behavior 12).
3325
3825
  *
@@ -3567,6 +4067,18 @@ var init_format2Observer = __esm({
3567
4067
  });
3568
4068
 
3569
4069
  // src/models/format2RecordStoreAsync.ts
4070
+ function widenDeferredReplay(held, note, epoch) {
4071
+ if (!note) {
4072
+ return held === null ? null : { ...held, toEpoch: Math.max(held.toEpoch, epoch) };
4073
+ }
4074
+ if (held === null) return { ...note };
4075
+ return {
4076
+ throughSeq: Math.max(held.throughSeq, note.throughSeq),
4077
+ fromEpoch: Math.min(held.fromEpoch, note.fromEpoch),
4078
+ toEpoch: Math.max(held.toEpoch, note.toEpoch),
4079
+ kind: note.kind
4080
+ };
4081
+ }
3570
4082
  function isFormat2RecordStoreAsync(value) {
3571
4083
  return value instanceof Format2RecordStoreAsync;
3572
4084
  }
@@ -3872,11 +4384,30 @@ var init_format2RecordStoreAsync = __esm({
3872
4384
  const store = this.direct();
3873
4385
  return store ? store.epoch() : this.mirrored().epoch;
3874
4386
  }
3875
- setEpoch(epoch) {
4387
+ /**
4388
+ * Move the epoch mark, with the judgement one deferred move owes (#3755).
4389
+ *
4390
+ * The note is settled in the STORE's transaction, not beside it, so a worker
4391
+ * host commits the pair or neither — which is the whole point of the note.
4392
+ */
4393
+ setEpoch(epoch, options = {}) {
3876
4394
  const store = this.direct();
3877
- if (store) store.setEpoch(epoch);
3878
- else this.mirrored().epoch = epoch;
3879
- if (!store) this.sendMark({ kind: "setEpoch", epoch });
4395
+ if (store) {
4396
+ store.setEpoch(epoch, options);
4397
+ return;
4398
+ }
4399
+ const marks = this.mirrored();
4400
+ marks.epoch = epoch;
4401
+ marks.deferredReplay = widenDeferredReplay(
4402
+ marks.deferredReplay,
4403
+ options.deferredReplay,
4404
+ epoch
4405
+ );
4406
+ this.sendMark({
4407
+ kind: "setEpoch",
4408
+ epoch,
4409
+ ...options.deferredReplay ? { deferredReplay: options.deferredReplay } : {}
4410
+ });
3880
4411
  }
3881
4412
  ackedSeq() {
3882
4413
  const store = this.direct();
@@ -3928,6 +4459,39 @@ var init_format2RecordStoreAsync = __esm({
3928
4459
  const store = this.direct();
3929
4460
  return store ? store.discontinuityEpochs() : [...this.mirrored().discontinuityEpochs];
3930
4461
  }
4462
+ /**
4463
+ * The judgement this client owes and has not made (#3755), or `null`.
4464
+ *
4465
+ * Synchronous, because every sequence claim consults it: a frame that
4466
+ * claimed a covered sequence would have the server acknowledge — and this
4467
+ * client prune — a write whose content is on no frame at all.
4468
+ */
4469
+ deferredReplay() {
4470
+ const store = this.direct();
4471
+ return store ? store.deferredReplay() : this.mirrored().deferredReplay;
4472
+ }
4473
+ /** Record that a judgement is owed, durably, before the move it covers. */
4474
+ async noteDeferredReplay(note) {
4475
+ await this.run(
4476
+ "noteDeferredReplay",
4477
+ () => this.host.dispatch({ kind: "noteDeferredReplay", note })
4478
+ );
4479
+ this.refreshMirror({
4480
+ deferredReplay: widenDeferredReplay(
4481
+ this.mirror?.deferredReplay ?? null,
4482
+ note,
4483
+ note.toEpoch
4484
+ )
4485
+ });
4486
+ }
4487
+ /** The judgement has been made: forget the note. */
4488
+ async clearDeferredReplay() {
4489
+ await this.run(
4490
+ "clearDeferredReplay",
4491
+ () => this.host.dispatch({ kind: "clearDeferredReplay" })
4492
+ );
4493
+ this.refreshMirror({ deferredReplay: null });
4494
+ }
3931
4495
  /** Whether the base this view is on finished loading (#3435). */
3932
4496
  baseComplete() {
3933
4497
  const store = this.direct();
@@ -4736,7 +5300,10 @@ function readFormat2StoreMarks(store) {
4736
5300
  // #3435 — the two facts a document that crossed a bulk load has to know
4737
5301
  // at bind, before it decides what the handshake means.
4738
5302
  discontinuityEpochs: store.discontinuityEpochs(),
4739
- baseComplete: store.baseState().base?.complete ?? false
5303
+ baseComplete: store.baseState().base?.complete ?? false,
5304
+ // #3755 — read on the same round trip as the rest, because the claim path
5305
+ // reads it synchronously off the mirror.
5306
+ deferredReplay: store.deferredReplay()
4740
5307
  };
4741
5308
  }
4742
5309
  function dispatchFormat2StoreCommand(store, sql, command) {
@@ -4757,7 +5324,10 @@ function run(store, sql, command) {
4757
5324
  return Number.isFinite(epoch) ? epoch : null;
4758
5325
  }
4759
5326
  case "setEpoch":
4760
- return store.setEpoch(command.epoch);
5327
+ return store.setEpoch(
5328
+ command.epoch,
5329
+ command.deferredReplay ? { deferredReplay: command.deferredReplay } : {}
5330
+ );
4761
5331
  case "noteSync":
4762
5332
  return store.noteSync(command.at, command.windowDays);
4763
5333
  case "noteOfflineWindow":
@@ -4840,6 +5410,10 @@ function run(store, sql, command) {
4840
5410
  return store.beginBase(command.buildId, command.epoch);
4841
5411
  case "completeBase":
4842
5412
  return store.completeBase(command.buildId);
5413
+ case "noteDeferredReplay":
5414
+ return store.noteDeferredReplay(command.note);
5415
+ case "clearDeferredReplay":
5416
+ return store.clearDeferredReplay();
4843
5417
  case "noteDiscontinuity":
4844
5418
  return store.noteDiscontinuity(command.epoch);
4845
5419
  case "clearDiscontinuities":
@@ -4853,6 +5427,22 @@ function run(store, sql, command) {
4853
5427
  forgetQueryProjection(sql, store.documentId());
4854
5428
  return void 0;
4855
5429
  }
5430
+ case "purge": {
5431
+ const docId = store.documentId();
5432
+ return sql.transaction(() => {
5433
+ const pendingOps = Format2RecordStore.pendingOpCount(sql, docId);
5434
+ if (!command.force && pendingOps > 0) {
5435
+ return { purged: false, reason: "pending", pendingOps };
5436
+ }
5437
+ const report = Format2RecordStore.purge(sql, docId);
5438
+ return {
5439
+ purged: true,
5440
+ ...report.nothingStored ? { nothingStored: true } : {},
5441
+ pendingOps: report.pendingOps,
5442
+ remainingDocuments: report.remainingDocuments
5443
+ };
5444
+ });
5445
+ }
4856
5446
  case "projectionMark": {
4857
5447
  if (command.op === "is") {
4858
5448
  return command.model === void 0 ? false : queryProjectionIsCurrent(sql, command.docId, command.model);
@@ -4876,6 +5466,7 @@ function run(store, sql, command) {
4876
5466
  var init_format2StoreDispatcher = __esm({
4877
5467
  "src/models/format2StoreDispatcher.ts"() {
4878
5468
  "use strict";
5469
+ init_format2RecordStore();
4879
5470
  init_format2QueryProjection();
4880
5471
  }
4881
5472
  });
@@ -5216,6 +5807,14 @@ var init_OrmContext = __esm({
5216
5807
  globalDefaultDocId = void 0;
5217
5808
  defaultDocChangedListeners = /* @__PURE__ */ new Set();
5218
5809
  modelDocMappingChangedListeners = /* @__PURE__ */ new Set();
5810
+ /**
5811
+ * Told when a local write on a large document was refused because the
5812
+ * document is past its offline window (#3758).
5813
+ *
5814
+ * Per instance, like every other listener set here (#3039): two `initJsBao`
5815
+ * instances in one process must not hear each other's refusals.
5816
+ */
5817
+ format2WriteRefusedListeners = /* @__PURE__ */ new Set();
5219
5818
  /**
5220
5819
  * Format-2 ("large document", #2816) record stores by document id.
5221
5820
  *
@@ -5225,6 +5824,19 @@ var init_OrmContext = __esm({
5225
5824
  format2Documents = /* @__PURE__ */ new Map();
5226
5825
  /** Format-2 epoch moves in flight (#2816). */
5227
5826
  format2Transitions = /* @__PURE__ */ new Map();
5827
+ /**
5828
+ * This instance's document observers' database writes that have not finished
5829
+ * yet, by document id (#3023).
5830
+ *
5831
+ * A Yjs observer fires synchronously with the update but writes to the
5832
+ * database asynchronously, so an update that has been APPLIED to a document
5833
+ * is not yet QUERYABLE. Whoever needs the difference — an `open()` that
5834
+ * promises its caller the synced records — waits on
5835
+ * {@link whenDocumentIndexed}. Per instance, not process-wide: two clients
5836
+ * holding the same document id write to their own engines, and neither
5837
+ * should be held up by the other's writes.
5838
+ */
5839
+ documentIndexingWork = /* @__PURE__ */ new Map();
5228
5840
  /**
5229
5841
  * Who this ORM instance is in a format-2 store's unacknowledged-write log
5230
5842
  * (#3431).
@@ -5402,6 +6014,56 @@ var init_OrmContext = __esm({
5402
6014
  release();
5403
6015
  }
5404
6016
  }
6017
+ /**
6018
+ * Record one observer's asynchronous write of `docId` (#3023), so a caller
6019
+ * that needs the document queryable can wait for it. Failures are the
6020
+ * observer's own to log; a tracked promise never rejects, because the point
6021
+ * of the barrier is only to know when the write is over.
6022
+ *
6023
+ * A destroyed context tracks nothing: its observers are already inert, and a
6024
+ * torn-down instance must not leave a barrier that never drains.
6025
+ */
6026
+ trackDocumentIndexing(docId, work) {
6027
+ if (this.destroyed) return;
6028
+ let inFlight = this.documentIndexingWork.get(docId);
6029
+ if (!inFlight) {
6030
+ inFlight = /* @__PURE__ */ new Set();
6031
+ this.documentIndexingWork.set(docId, inFlight);
6032
+ }
6033
+ const pending = work.catch(() => {
6034
+ }).then(() => {
6035
+ inFlight.delete(pending);
6036
+ if (inFlight.size === 0 && this.documentIndexingWork.get(docId) === inFlight) {
6037
+ this.documentIndexingWork.delete(docId);
6038
+ }
6039
+ });
6040
+ inFlight.add(pending);
6041
+ }
6042
+ /**
6043
+ * Resolve once `docId`'s applied changes have reached this instance's query
6044
+ * tables (#3023). Answers `true` when the work drained, `false` when it gave
6045
+ * up waiting — the caller owns the logging, because this module holds no
6046
+ * runtime imports.
6047
+ *
6048
+ * An update applied to the Y.Doc is visible to a reader of the document
6049
+ * immediately, but the query tables only catch up when the observers' writes
6050
+ * land. An `open()` that waited for the initial sync waits for this too —
6051
+ * otherwise it resolves on the sync signal and the caller's first `query()`
6052
+ * sees a partly-written table.
6053
+ *
6054
+ * A write can enqueue more work (a nested map observed as it arrives), so
6055
+ * this drains in passes rather than once, and gives up after enough of them
6056
+ * that only a document under continuous change could still be busy — a
6057
+ * barrier is not a reason to hang.
6058
+ */
6059
+ async whenDocumentIndexed(docId) {
6060
+ for (let pass = 0; pass < 50; pass++) {
6061
+ const inFlight = this.documentIndexingWork.get(docId);
6062
+ if (!inFlight || inFlight.size === 0) return true;
6063
+ await Promise.all([...inFlight]);
6064
+ }
6065
+ return false;
6066
+ }
5405
6067
  /** Notify `subscribe()` callbacks registered on this context for `modelName`. */
5406
6068
  notifyListeners(modelName, log, onError) {
5407
6069
  const listeners = this.listenersMap.get(modelName);
@@ -5460,6 +6122,35 @@ var init_OrmContext = __esm({
5460
6122
  this.modelDocMappingChangedListeners.add(listener);
5461
6123
  return () => this.modelDocMappingChangedListeners.delete(listener);
5462
6124
  }
6125
+ /** Hear every write this instance's documents refuse past their window. */
6126
+ onFormat2WriteRefused(listener) {
6127
+ this.format2WriteRefusedListeners.add(listener);
6128
+ return () => this.format2WriteRefusedListeners.delete(listener);
6129
+ }
6130
+ /**
6131
+ * Tell them, and swallow what they throw.
6132
+ *
6133
+ * A listener is application code, and a write's outcome is decided by the
6134
+ * window and not by whoever was listening: a handler that throws must
6135
+ * neither change what the write throws nor stop the next handler
6136
+ * (#3758, behavior 6).
6137
+ *
6138
+ * Over a SNAPSHOT of the set, so one report reaches each listener exactly
6139
+ * once however the handlers subscribe and unsubscribe while it is being
6140
+ * delivered. A `Set` visits an element re-added during its own iteration,
6141
+ * so a handler that resubscribes itself would otherwise hear the same
6142
+ * refusal again — and one that does so unconditionally would loop for ever,
6143
+ * never letting the write's rejection reach its caller (finding 3758-C02).
6144
+ */
6145
+ reportFormat2WriteRefused(event) {
6146
+ for (const listener of [...this.format2WriteRefusedListeners]) {
6147
+ if (!this.format2WriteRefusedListeners.has(listener)) continue;
6148
+ try {
6149
+ listener(event);
6150
+ } catch {
6151
+ }
6152
+ }
6153
+ }
5463
6154
  /** Drop the model→document defaults that point at a disconnected document. */
5464
6155
  clearMappingsForDocId(docId) {
5465
6156
  const toRemove = [];
@@ -5525,8 +6216,10 @@ var init_OrmContext = __esm({
5525
6216
  this.globalDefaultDocId = void 0;
5526
6217
  this.defaultDocChangedListeners.clear();
5527
6218
  this.modelDocMappingChangedListeners.clear();
6219
+ this.format2WriteRefusedListeners.clear();
5528
6220
  this.format2Documents.clear();
5529
6221
  this.format2Transitions.clear();
6222
+ this.documentIndexingWork.clear();
5530
6223
  this.observedStringSetMaps = /* @__PURE__ */ new WeakSet();
5531
6224
  liveOrmContexts.delete(this);
5532
6225
  if (defaultOrmContext === this) {
@@ -5897,6 +6590,10 @@ var init_ModelRegistry = __esm({
5897
6590
  * undefined the legacy session semantics are kept exactly: the global
5898
6591
  * registry is SNAPSHOT at init time, so a model registered afterwards is not
5899
6592
  * silently adopted by an already-running instance.
6593
+ *
6594
+ * The one way a running instance's registry grows is {@link adoptModel},
6595
+ * behind `registerModel` on the init result (#3615): an EXPLICIT call on the
6596
+ * instance the app means, not a relaxation of the snapshot rule.
5900
6597
  */
5901
6598
  static createInstanceRegistry(modelClasses) {
5902
6599
  const registry = new _ModelRegistry();
@@ -5920,6 +6617,64 @@ var init_ModelRegistry = __esm({
5920
6617
  registry.setExplicitModelsForSession(modelClasses);
5921
6618
  return registry;
5922
6619
  }
6620
+ /**
6621
+ * The name `modelClass` may be adopted under on THIS instance, or a refusal
6622
+ * that names what to change (#3615).
6623
+ *
6624
+ * Both checks are made before anything is mutated, so a registration that
6625
+ * cannot go through leaves the registry exactly as it was.
6626
+ */
6627
+ assertAdoptable(modelClass, instanceId) {
6628
+ const schema = modelClass?.getSchema?.();
6629
+ const modelName = modelClass?.modelName ?? schema?.options?.name;
6630
+ if (!modelName || !schema?.options?.name || !schema?.resolvedUniqueConstraints) {
6631
+ throw new Error(
6632
+ `[initJsBao] Cannot register a model class with no name. Build it with defineModelSchema and attachAndRegisterModel (or createModelClass) so it carries a static modelName and a schema with resolvedUniqueConstraints.`
6633
+ );
6634
+ }
6635
+ const existing = this.models.get(modelName);
6636
+ if (existing && existing !== modelClass) {
6637
+ throw new Error(
6638
+ `[initJsBao] Model '${modelName}' is already registered on js-bao instance #${instanceId} with a different class. Register the existing class, or give the new model a different name.`
6639
+ );
6640
+ }
6641
+ return modelName;
6642
+ }
6643
+ /**
6644
+ * Adopt `modelClass` into THIS instance's registry at runtime (#3615).
6645
+ *
6646
+ * The counterpart to the snapshot rule on
6647
+ * {@link ModelRegistry.createInstanceRegistry}: an instance grows only where
6648
+ * the app says so, and the global singleton is deliberately not touched —
6649
+ * registering here makes the model visible to this instance and to no other.
6650
+ *
6651
+ * Returns `{ added: false }` when the very same class is already ACTIVE on
6652
+ * this instance, which is what makes a repeat `registerModel` a no-op rather
6653
+ * than a warning about overwriting. Active, not merely present: an instance
6654
+ * built with `models: []` carries the whole global registry in `models` with
6655
+ * nothing active, and a class only the backing snapshot holds has neither
6656
+ * been validated for this session nor bound to its context, so it still has
6657
+ * an adoption to go through.
6658
+ */
6659
+ adoptModel(modelClass, options = {}) {
6660
+ const modelName = this.assertAdoptable(modelClass, options.instanceId);
6661
+ if (this.getActiveModels().get(modelName) === modelClass) {
6662
+ return { added: false };
6663
+ }
6664
+ const candidate = new Map(this.getActiveModels());
6665
+ candidate.set(modelName, modelClass);
6666
+ this.validateModels(candidate);
6667
+ const schema = modelClass.getSchema?.();
6668
+ this.models.set(modelName, modelClass);
6669
+ if (schema?.options) this.modelOptions.set(modelName, schema.options);
6670
+ if (this.activeSessionModels) {
6671
+ this.activeSessionModels.set(modelName, modelClass);
6672
+ }
6673
+ Logger.debug(
6674
+ `[ModelRegistry] Model "${modelName}" adopted by instance #${options.instanceId} at runtime.`
6675
+ );
6676
+ return { added: true };
6677
+ }
5923
6678
  registerModel(modelClass, options, fields) {
5924
6679
  if (!options.name) {
5925
6680
  throw new Error(
@@ -6122,7 +6877,8 @@ var init_ModelRegistry = __esm({
6122
6877
  Logger.debug(`[ModelRegistry] Data removal complete for document ${docId}`);
6123
6878
  }
6124
6879
  // New method to initialize relationships
6125
- async initializeRelationships() {
6880
+ async initializeRelationships(fallbackDbEngine) {
6881
+ if (!this.dbEngine && fallbackDbEngine) this.dbEngine = fallbackDbEngine;
6126
6882
  if (!this.dbEngine) {
6127
6883
  console.error(
6128
6884
  "[ModelRegistry] DB engine not available for initializing relationships. Ensure initializeAll has been called."
@@ -6274,7 +7030,16 @@ var init_ModelRegistry = __esm({
6274
7030
  return this.activeSessionModels || this.models;
6275
7031
  }
6276
7032
  validateSessionModels() {
6277
- const activeModels = this.getActiveModels();
7033
+ this.validateModels(this.getActiveModels());
7034
+ }
7035
+ /**
7036
+ * Every relationship of `activeModels` names a model that is also in it.
7037
+ *
7038
+ * Taken as an argument rather than read off the registry so a runtime
7039
+ * adoption can be validated against what the instance WOULD hold before it
7040
+ * holds it (#3615).
7041
+ */
7042
+ validateModels(activeModels) {
6278
7043
  if (!activeModels || activeModels.size === 0) {
6279
7044
  return;
6280
7045
  }
@@ -6726,6 +7491,14 @@ var init_IncludeResolver = __esm({
6726
7491
  function generateULID() {
6727
7492
  return (0, import_ulid2.ulid)();
6728
7493
  }
7494
+ function reindexFailure(modelConstructor, modelName, recordId, docId, error) {
7495
+ const message = error instanceof Error ? error.message : String(error);
7496
+ const failure = new Error(
7497
+ `[${modelConstructor.name}] Error re-indexing ${modelName}/${recordId} of document ${docId}: ${message}`
7498
+ );
7499
+ failure.cause = error;
7500
+ return failure;
7501
+ }
6729
7502
  var Y7, import_ulid2, FORMAT2_ROW_PAGE, UniqueConstraintViolationError, RecordNotFoundError, SCHEMA_ACCESSORS_KEY, BaseModel2;
6730
7503
  var init_BaseModel = __esm({
6731
7504
  "src/models/BaseModel.ts"() {
@@ -6982,21 +7755,56 @@ var init_BaseModel = __esm({
6982
7755
  }
6983
7756
  }
6984
7757
  /**
6985
- * Build an instance already holding its merged-view row (#3429).
7758
+ * The one gate every local write on a large document passes (#3758).
6986
7759
  *
6987
- * Every format-2 read path goes through this, so an instance a read produced
6988
- * never has to go back for its own values: the row it was found by IS its
6989
- * cache entry, shared with every other live instance of that record.
7760
+ * Two doors reach it — `save()` and `delete()` — and both have to refuse
7761
+ * for the same reasons, report the same way and throw the same error. Two
7762
+ * copies of "report, log, then throw" is two chances for them to drift, so
7763
+ * the sequence is stated once, here, mirroring the single boundary Swift
7764
+ * keeps in `Format2WritePath`.
7765
+ *
7766
+ * Runs BEFORE anything is planned or committed, so a refused write leaves
7767
+ * no merged row and no pending op behind; the report reaches the listeners
7768
+ * synchronously, before the rejection reaches the caller.
6990
7769
  */
6991
- static format2Instance(ctor, store, modelName, docId, recordId, row) {
6992
- const entry = store.entryFor(modelName, recordId);
6993
- entry.row = row;
6994
- entry.loaded = true;
6995
- const instance = new ctor({ id: recordId });
6996
- instance._format2Row = entry;
6997
- instance._format2Unresolved = false;
6998
- instance._isLoadingFromYjs = true;
6999
- instance._isDirty = false;
7770
+ static assertFormat2Writable(docId, modelName, recordId, store) {
7771
+ store.assertReadable();
7772
+ try {
7773
+ assertWithinOfflineWindow(docId, store);
7774
+ } catch (error) {
7775
+ if (!(error instanceof DocumentOfflineWindowError)) throw error;
7776
+ Logger.warn(
7777
+ "[format2] write refused: past the offline window \u2014",
7778
+ docId,
7779
+ modelName,
7780
+ recordId,
7781
+ error.code
7782
+ );
7783
+ resolveOrmContext(this).reportFormat2WriteRefused({
7784
+ documentId: docId,
7785
+ model: modelName,
7786
+ recordId,
7787
+ error
7788
+ });
7789
+ throw error;
7790
+ }
7791
+ }
7792
+ /**
7793
+ * Build an instance already holding its merged-view row (#3429).
7794
+ *
7795
+ * Every format-2 read path goes through this, so an instance a read produced
7796
+ * never has to go back for its own values: the row it was found by IS its
7797
+ * cache entry, shared with every other live instance of that record.
7798
+ */
7799
+ static format2Instance(ctor, store, modelName, docId, recordId, row) {
7800
+ const entry = store.entryFor(modelName, recordId);
7801
+ entry.row = row;
7802
+ entry.loaded = true;
7803
+ const instance = new ctor({ id: recordId });
7804
+ instance._format2Row = entry;
7805
+ instance._format2Unresolved = false;
7806
+ instance._isLoadingFromYjs = true;
7807
+ instance._isDirty = false;
7000
7808
  instance._localChanges = null;
7001
7809
  instance._metaDocId = docId;
7002
7810
  instance._metaPermissionHint = resolveOrmContext(this).connectedDocuments.get(docId)?.permissionHint ?? "read-write";
@@ -7598,19 +8406,31 @@ var init_BaseModel = __esm({
7598
8406
  if (fieldOptions?.default === void 0) return void 0;
7599
8407
  return typeof fieldOptions.default === "function" ? fieldOptions.default() : fieldOptions.default;
7600
8408
  }
7601
- getStringSetFromYjs(fieldName) {
7602
- const yjsData = this.getFromYjs(fieldName);
7603
- if (yjsData instanceof Y7.Map) {
7604
- return Array.from(yjsData.keys());
8409
+ /**
8410
+ * The members of a stringset as it is STORED, in stored order.
8411
+ *
8412
+ * Static and value-only so that the two readers of a stored stringset — the
8413
+ * instance's `getStringSetFromYjs`, which feeds the value a save builds its
8414
+ * unique key from, and `_readableUniqueOwner`, which rebuilds another
8415
+ * record's key from the map — cannot drift apart (#3252).
8416
+ */
8417
+ static _stringSetMembersFromStored(stored) {
8418
+ if (stored instanceof Y7.Map) {
8419
+ return Array.from(stored.keys());
7605
8420
  }
7606
- if (Array.isArray(yjsData)) {
7607
- return yjsData.filter((member) => typeof member === "string");
8421
+ if (Array.isArray(stored)) {
8422
+ return stored.filter((member) => typeof member === "string");
7608
8423
  }
7609
- if (yjsData && typeof yjsData === "object") {
7610
- return Object.keys(yjsData).filter((k) => Boolean(yjsData[k]));
8424
+ if (stored && typeof stored === "object") {
8425
+ return Object.keys(stored).filter((k) => Boolean(stored[k]));
7611
8426
  }
7612
8427
  return [];
7613
8428
  }
8429
+ getStringSetFromYjs(fieldName) {
8430
+ return this.constructor._stringSetMembersFromStored(
8431
+ this.getFromYjs(fieldName)
8432
+ );
8433
+ }
7614
8434
  getOrCreateStringSet(fieldName) {
7615
8435
  if (!this._stringSetFields.has(fieldName)) {
7616
8436
  const initialValues = this.getStringSetFromYjs(fieldName);
@@ -7639,6 +8459,30 @@ var init_BaseModel = __esm({
7639
8459
  `[${this.name}] initialize(yDoc, db) is no longer supported. Use initializeForDocument(yDoc, db, docId, permission)`
7640
8460
  );
7641
8461
  }
8462
+ /**
8463
+ * Record one observer's asynchronous write of `docId` on this class's ORM
8464
+ * instance (#3023), so a caller that needs the document queryable can wait
8465
+ * for it. The observers themselves track through the context they captured
8466
+ * at install time; this static is for callers that only hold a model class.
8467
+ */
8468
+ static trackDocumentIndexing(docId, work) {
8469
+ resolveOrmContext(this).trackDocumentIndexing(docId, work);
8470
+ }
8471
+ /**
8472
+ * Resolve once `docId`'s applied changes have reached this class's instance
8473
+ * of the query tables (#3023). See
8474
+ * {@link OrmContext.whenDocumentIndexed} — an `open()` that waited for the
8475
+ * initial sync waits for this too, otherwise it resolves on the sync signal
8476
+ * and the caller's first `query()` sees a partly-written table.
8477
+ */
8478
+ static async whenDocumentIndexed(docId) {
8479
+ const drained = await resolveOrmContext(this).whenDocumentIndexed(docId);
8480
+ if (!drained) {
8481
+ Logger.warn(
8482
+ `[BaseModel] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
8483
+ );
8484
+ }
8485
+ }
7642
8486
  /**
7643
8487
  * Initialize this model for a connected document.
7644
8488
  *
@@ -7985,7 +8829,7 @@ var init_BaseModel = __esm({
7985
8829
  return recordIdsToDiscard;
7986
8830
  };
7987
8831
  const observerContext = resolveOrmContext(this);
7988
- const documentYMapObserver = async (event) => {
8832
+ const indexDocumentYMapChange = async (event) => {
7989
8833
  if (observerContext.destroyed) return;
7990
8834
  Logger.verbose(
7991
8835
  `[${this.name}] Document YMap change detected for ${modelName}/${docId}:`,
@@ -8154,6 +8998,12 @@ var init_BaseModel = __esm({
8154
8998
  );
8155
8999
  observerContext.notifyListeners(modelName);
8156
9000
  };
9001
+ const documentYMapObserver = (event) => {
9002
+ observerContext.trackDocumentIndexing(
9003
+ docId,
9004
+ indexDocumentYMapChange(event)
9005
+ );
9006
+ };
8157
9007
  documentYMap.observe(documentYMapObserver);
8158
9008
  observerContext.registerObserverDisposer(
8159
9009
  docId,
@@ -8179,6 +9029,116 @@ var init_BaseModel = __esm({
8179
9029
  `[${this.name}] Model ${modelName} initialization complete for document ${docId}.`
8180
9030
  );
8181
9031
  }
9032
+ /**
9033
+ * Re-index ONE record of a document from its current Y.Map state (#3615).
9034
+ *
9035
+ * A legacy document's initial load walks the Y.Map before the permanent
9036
+ * observers are installed, so an update that lands in that window reaches
9037
+ * Yjs and misses the query tables. For a document connected long ago — which
9038
+ * is exactly what a model registered at runtime is initialized against —
9039
+ * that window is open on a live document. `registerModel` collects the
9040
+ * record ids touched while it runs and brings each of them here afterwards,
9041
+ * writing the same row the permanent per-record observer would have written.
9042
+ *
9043
+ * A record the window DELETED is deleted here for the same reason.
9044
+ *
9045
+ * A storage failure REJECTS rather than being logged and passed over, the
9046
+ * way the permanent observers' writes are: this write is part of what a
9047
+ * registration promises before it resolves, so swallowing it would resolve a
9048
+ * registration whose query tables are missing the very updates this call
9049
+ * exists to catch, and mark the pair complete so no retry would repair it.
9050
+ */
9051
+ static async reindexDocumentRecord(docId, recordId) {
9052
+ const modelConstructor = this;
9053
+ const schema = modelConstructor.getSchema?.();
9054
+ const modelName = schema?.options?.name;
9055
+ if (!modelName) return;
9056
+ const ctx = resolveOrmContext(modelConstructor);
9057
+ if (ctx.destroyed) return;
9058
+ const documentYMap = modelConstructor.documentYMaps.get(
9059
+ `${docId}_${modelName}`
9060
+ );
9061
+ if (!documentYMap) return;
9062
+ const engine = ctx.dbEngine ? await modelConstructor.queryEngineFor([docId], modelName) : null;
9063
+ if (!engine) return;
9064
+ const recordData = documentYMap.get(recordId);
9065
+ if (recordData === void 0 || recordData === null) {
9066
+ try {
9067
+ await engine.delete(modelName, recordId);
9068
+ } catch (error) {
9069
+ throw reindexFailure(
9070
+ modelConstructor,
9071
+ modelName,
9072
+ recordId,
9073
+ docId,
9074
+ error
9075
+ );
9076
+ }
9077
+ ctx.notifyListeners(modelName);
9078
+ return;
9079
+ }
9080
+ const entries = recordData instanceof Y7.Map ? Array.from(recordData.entries()) : Object.entries(recordData);
9081
+ const rowData = {};
9082
+ for (const [fieldKey, value] of entries) {
9083
+ const fieldOptions = schema.fields?.get(fieldKey);
9084
+ if (!fieldOptions || fieldOptions.type === "stringset") continue;
9085
+ if (value === void 0) continue;
9086
+ rowData[fieldKey] = isYjsComposite(value) ? normalizeYjsValueForStorage(value) : value;
9087
+ }
9088
+ if (!rowData.id) return;
9089
+ const permissionHint = modelConstructor.connectedDocuments.get(docId)?.permissionHint ?? "read-write";
9090
+ try {
9091
+ await engine.insert(modelName, {
9092
+ ...rowData,
9093
+ type: modelName,
9094
+ _meta_doc_id: docId,
9095
+ _meta_permission_hint: permissionHint
9096
+ });
9097
+ } catch (error) {
9098
+ throw reindexFailure(
9099
+ modelConstructor,
9100
+ modelName,
9101
+ recordId,
9102
+ docId,
9103
+ error
9104
+ );
9105
+ }
9106
+ ctx.notifyListeners(modelName);
9107
+ }
9108
+ /**
9109
+ * Withdraw ONE document's registration of this model (#3615).
9110
+ *
9111
+ * A `registerModel` that fails part-way through a document keeps the class
9112
+ * registered, because retrying is the recovery. This is what keeps that
9113
+ * half-finished pair from LOOKING finished in the meantime: without the
9114
+ * Y.Map mapping a save raises the same `YMap not found` an unregistered
9115
+ * model raises, instead of writing into a document whose observers were
9116
+ * never installed, and clearing the rows the failed load managed to write
9117
+ * keeps a query from answering out of a document nothing is watching. The
9118
+ * retry's `initializeForDocument` publishes both again.
9119
+ *
9120
+ * `connectedDocuments` is deliberately left alone: it is the CONTEXT's map,
9121
+ * shared by every model on the instance, so the document itself is still
9122
+ * connected for everything else.
9123
+ */
9124
+ static async withdrawDocumentRegistration(docId) {
9125
+ const modelConstructor = this;
9126
+ const schema = modelConstructor.getSchema?.();
9127
+ const modelName = schema?.options?.name;
9128
+ if (!modelName) return;
9129
+ modelConstructor.documentYMaps.delete(`${docId}_${modelName}`);
9130
+ const ctx = resolveOrmContext(modelConstructor);
9131
+ if (ctx.destroyed || !ctx.dbEngine) return;
9132
+ try {
9133
+ const engine = await modelConstructor.queryEngineFor([docId], modelName);
9134
+ await engine.deleteByDocumentId(modelName, docId);
9135
+ } catch (error) {
9136
+ Logger.warn(
9137
+ `[${modelConstructor.name}] Could not clear ${modelName} rows of document ${docId} while withdrawing an incomplete registration:`,
9138
+ error
9139
+ );
9140
+ }
9141
+ }
8182
9142
  // New method to clean up data for a disconnected document
8183
9143
  static async cleanupDocumentData(docId) {
8184
9144
  const modelConstructor = this;
@@ -8437,6 +9397,48 @@ var init_BaseModel = __esm({
8437
9397
  constraintName
8438
9398
  );
8439
9399
  }
9400
+ /**
9401
+ * The record that READABLY owns `uniqueKey` under `constraint` in one
9402
+ * document's model map, or `null`.
9403
+ *
9404
+ * The `_uniqueIdx_` map is a cache over the records, not the record store,
9405
+ * and the two can disagree (#3252): a replica can hold an index entry for a
9406
+ * record whose structs have not landed yet, or an entry a field change or
9407
+ * delete left behind. `save()` used to take the entry at its word, so the
9408
+ * app saw a `query` that returned no row for a value and a save refused for
9409
+ * that same value — the index knew a row nothing else could read.
9410
+ *
9411
+ * An entry counts only when its owner is readable: the record map exists
9412
+ * under that id in `modelYMap`, and the key built from the record's stored
9413
+ * fields is `uniqueKey`. Anything else is a stale entry, and the caller
9414
+ * treats it as an index miss — the engine fallback decides, exactly as it
9415
+ * does for a missing entry — so the write that follows lands and rewrites
9416
+ * the entry to the record that really holds the value.
9417
+ *
9418
+ * "The key built from the record's stored fields" has to mean the key that
9419
+ * record's own save built, so the stored value is read the way the save
9420
+ * read it: a stringset is stored as a nested Y.Map of its members but keyed
9421
+ * as the array `getCurrentJSState` puts in `dataToSave` (finding 3252-01).
9422
+ * Comparing the raw Y.Map would call every readable owner of a unique
9423
+ * stringset stale and let a duplicate straight through the constraint.
9424
+ */
9425
+ static _readableUniqueOwner(modelYMap, constraintMap, constraint, uniqueKey, modelName) {
9426
+ const ownerId = constraintMap.get(uniqueKey);
9427
+ if (!ownerId) return null;
9428
+ const recordYMap = modelYMap?.get(ownerId);
9429
+ if (!(recordYMap instanceof Y7.Map)) return null;
9430
+ const fields = this.getSchema?.()?.fields;
9431
+ const heldKey = this._buildKeyFromValues(
9432
+ constraint.fields,
9433
+ constraint.fields.map((field) => {
9434
+ const stored = recordYMap.get(field);
9435
+ return fields?.get(field)?.type === "stringset" ? this._stringSetMembersFromStored(stored) : stored;
9436
+ }),
9437
+ modelName,
9438
+ constraint.name
9439
+ );
9440
+ return heldKey === uniqueKey ? ownerId : null;
9441
+ }
8440
9442
  /**
8441
9443
  * The connected format-2 document that holds this instance's record, for an
8442
9444
  * instance the constructor could not resolve (#3429, finding 3429-03).
@@ -8585,8 +9587,12 @@ var init_BaseModel = __esm({
8585
9587
  }
8586
9588
  const format2Store = modelConstructor.getFormat2StoreAsync(targetDocId);
8587
9589
  if (format2Store) {
8588
- format2Store.assertReadable();
8589
- assertWithinOfflineWindow(targetDocId, format2Store);
9590
+ this.constructor.assertFormat2Writable(
9591
+ targetDocId,
9592
+ modelName,
9593
+ this.id,
9594
+ format2Store
9595
+ );
8590
9596
  const target = await this.constructor.resolveFormat2Target(
8591
9597
  targetDocId,
8592
9598
  modelName,
@@ -8634,7 +9640,14 @@ var init_BaseModel = __esm({
8634
9640
  const constraintMap = yDoc.getMap(
8635
9641
  uniqueIndexMapName(modelName, constraint.name)
8636
9642
  );
8637
- if (!constraintMap.get(newUniqueKey)) {
9643
+ const readableOwner = modelConstructor._readableUniqueOwner(
9644
+ targetYMap,
9645
+ constraintMap,
9646
+ constraint,
9647
+ newUniqueKey,
9648
+ modelName
9649
+ );
9650
+ if (!readableOwner) {
8638
9651
  const constraintKeyValues = constraint.fields.map(
8639
9652
  (field) => dataToSave[field]
8640
9653
  );
@@ -8652,7 +9665,13 @@ var init_BaseModel = __esm({
8652
9665
  if (upsertOnConstraint && upsertOnKey) {
8653
9666
  const constraintMapName = `_uniqueIdx_${modelName}_${upsertOnConstraint.name}`;
8654
9667
  const constraintMap = yDoc.getMap(constraintMapName);
8655
- const existingId = constraintMap.get(upsertOnKey);
9668
+ const existingId = modelConstructor._readableUniqueOwner(
9669
+ targetYMap ?? void 0,
9670
+ constraintMap,
9671
+ upsertOnConstraint,
9672
+ upsertOnKey,
9673
+ modelName
9674
+ );
8656
9675
  if (existingId) {
8657
9676
  if (this._constructorProvidedId && this.id !== existingId) {
8658
9677
  throw new Error(
@@ -8752,7 +9771,13 @@ var init_BaseModel = __esm({
8752
9771
  50
8753
9772
  )}'`
8754
9773
  );
8755
- const existingRecordIdWithNewKey = constraintMap.get(newUniqueKey);
9774
+ const existingRecordIdWithNewKey = modelConstructor._readableUniqueOwner(
9775
+ targetYMap ?? void 0,
9776
+ constraintMap,
9777
+ constraint,
9778
+ newUniqueKey,
9779
+ modelName
9780
+ );
8756
9781
  Logger.verbose(
8757
9782
  `[${modelName}] Save Check (Item '${this.id}'): Found Existing ID: '${existingRecordIdWithNewKey}' for Key '${newUniqueKey.substring(
8758
9783
  0,
@@ -9341,8 +10366,12 @@ var init_BaseModel = __esm({
9341
10366
  sourceYMap = modelConstructor.documentYMaps.get(`${docId}_${modelName}`) || null;
9342
10367
  const format2Store = modelConstructor.getFormat2StoreAsync(docId);
9343
10368
  if (format2Store) {
9344
- format2Store.assertReadable();
9345
- assertWithinOfflineWindow(docId, format2Store);
10369
+ modelConstructor.assertFormat2Writable(
10370
+ docId,
10371
+ modelName,
10372
+ this.id,
10373
+ format2Store
10374
+ );
9346
10375
  const target = await modelConstructor.resolveFormat2Target(
9347
10376
  docId,
9348
10377
  modelName,
@@ -9367,10 +10396,18 @@ var init_BaseModel = __esm({
9367
10396
  }
9368
10397
  const recordYMap = sourceYMap.get(this.id);
9369
10398
  if (!recordYMap) {
9370
- Logger.warn(
9371
- `[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}. Cannot remove from unique constraint maps. Attempting main map deletion only.`
9372
- );
9373
10399
  sourceYDoc.transact(() => {
10400
+ const removed = removeUniqueIndexEntriesForRecordId(
10401
+ sourceYDoc,
10402
+ modelName,
10403
+ schema.resolvedUniqueConstraints.map((c) => c.name),
10404
+ this.id
10405
+ );
10406
+ if (removed > 0) {
10407
+ Logger.verbose(
10408
+ `[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}; removed ${removed} unique-index entr${removed === 1 ? "y" : "ies"} still naming it.`
10409
+ );
10410
+ }
9374
10411
  sourceYMap.delete(this.id);
9375
10412
  }, `delete-ghost-record-${modelName}-${this.id}`);
9376
10413
  return;
@@ -9390,6 +10427,7 @@ var init_BaseModel = __esm({
9390
10427
  if (uniqueKey !== null) {
9391
10428
  const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
9392
10429
  const constraintMap = sourceYDoc.getMap(constraintMapName);
10430
+ if (constraintMap.get(uniqueKey) !== this.id) continue;
9393
10431
  Logger.verbose(
9394
10432
  `[${modelName}] Delete: Removing unique key '${uniqueKey.substring(
9395
10433
  0,
@@ -9659,13 +10697,17 @@ var init_BaseModel = __esm({
9659
10697
  }
9660
10698
  const isFirstPage = !options?.uniqueStartKey;
9661
10699
  const direction = options?.direction || 1;
10700
+ const aliasedSortFields = translatedQuery.cursorSortValueFields ?? [];
9662
10701
  const cursors = CursorManager.generateResultCursors(
9663
- results,
10702
+ results.map((row) => cursorMintingRecord(row, aliasedSortFields)),
9664
10703
  translatedQuery.sortFields,
9665
10704
  direction,
9666
10705
  hasMore,
9667
10706
  isFirstPage
9668
10707
  );
10708
+ results = results.map(
10709
+ (row) => stripCursorSortValues(row, aliasedSortFields)
10710
+ );
9669
10711
  if (direction === -1) {
9670
10712
  results.reverse();
9671
10713
  }
@@ -10437,24 +11479,25 @@ var init_BaseModel = __esm({
10437
11479
  constraint.name
10438
11480
  );
10439
11481
  const constraintMap2 = yDoc.getMap(constraintMapName2);
10440
- const recordId2 = constraintMap2.get(uniqueKeyString);
11482
+ const documentYMapKey = `${docId}_${currentModelName}`;
11483
+ const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
11484
+ const recordId2 = modelConstructor._readableUniqueOwner(
11485
+ documentYMap,
11486
+ constraintMap2,
11487
+ constraint,
11488
+ uniqueKeyString,
11489
+ currentModelName
11490
+ );
10441
11491
  if (recordId2) {
10442
- const documentYMapKey = `${docId}_${currentModelName}`;
10443
- const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
10444
- if (documentYMap) {
10445
- const recordYMap2 = documentYMap.get(recordId2);
10446
- if (recordYMap2 && recordYMap2 instanceof Y7.Map) {
10447
- const instance = new modelConstructor({
10448
- id: recordId2
10449
- });
10450
- instance._metaDocId = docId;
10451
- instance._metaPermissionHint = documentInfo.permissionHint;
10452
- Logger.verbose(
10453
- `[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
10454
- );
10455
- return instance;
10456
- }
10457
- }
11492
+ const instance = new modelConstructor({
11493
+ id: recordId2
11494
+ });
11495
+ instance._metaDocId = docId;
11496
+ instance._metaPermissionHint = documentInfo.permissionHint;
11497
+ Logger.verbose(
11498
+ `[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
11499
+ );
11500
+ return instance;
10458
11501
  }
10459
11502
  }
10460
11503
  const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
@@ -10485,7 +11528,13 @@ var init_BaseModel = __esm({
10485
11528
  constraint.name
10486
11529
  );
10487
11530
  const constraintMap = legacyDocInfo.yDoc.getMap(constraintMapName);
10488
- const recordId = constraintMap.get(uniqueKeyString);
11531
+ const recordId = modelConstructor._readableUniqueOwner(
11532
+ legacyDocumentMap,
11533
+ constraintMap,
11534
+ constraint,
11535
+ uniqueKeyString,
11536
+ currentModelName
11537
+ );
10489
11538
  if (!recordId) {
10490
11539
  const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
10491
11540
  constraint,
@@ -10571,7 +11620,13 @@ var init_BaseModel = __esm({
10571
11620
  const docInfo = connectedDocuments.get(docId);
10572
11621
  if (docInfo) {
10573
11622
  const constraintMap = docInfo.yDoc.getMap(constraintMapName);
10574
- const recordId = constraintMap.get(uniqueKeyString);
11623
+ const recordId = modelConstructor._readableUniqueOwner(
11624
+ modelConstructor.documentYMaps?.get(`${docId}_${currentModelName}`),
11625
+ constraintMap,
11626
+ constraint,
11627
+ uniqueKeyString,
11628
+ currentModelName
11629
+ );
10575
11630
  if (recordId) {
10576
11631
  existingRecord = await modelConstructor.find(
10577
11632
  recordId
@@ -10890,7 +11945,7 @@ var init_BaseModel = __esm({
10890
11945
  );
10891
11946
  modelConstructor.attachStringSetObserversToRecord(recordYMap, schema, docId);
10892
11947
  const observerContext = resolveOrmContext(modelConstructor);
10893
- const recordObserver = async (event) => {
11948
+ const indexNestedYMapChange = async (event) => {
10894
11949
  if (observerContext.destroyed) return;
10895
11950
  Logger.verbose(
10896
11951
  `[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
@@ -10966,6 +12021,12 @@ var init_BaseModel = __esm({
10966
12021
  }
10967
12022
  observerContext.notifyListeners(modelName);
10968
12023
  };
12024
+ const recordObserver = (event) => {
12025
+ observerContext.trackDocumentIndexing(
12026
+ docId,
12027
+ indexNestedYMapChange(event)
12028
+ );
12029
+ };
10969
12030
  recordYMap.observe(recordObserver);
10970
12031
  observerContext.registerObserverDisposer(
10971
12032
  docId,
@@ -11031,6 +12092,7 @@ __export(browser_exports, {
11031
12092
  completeOverlayEntries: () => completeOverlayEntries,
11032
12093
  configuredOfflineWindowDays: () => configuredOfflineWindowDays,
11033
12094
  createModelClass: () => createModelClass,
12095
+ decideDeferredReplay: () => decideDeferredReplay,
11034
12096
  decodeFormat2StoreError: () => decodeFormat2StoreError,
11035
12097
  defineModelSchema: () => defineModelSchema,
11036
12098
  detectEnvironment: () => detectEnvironment,
@@ -11041,10 +12103,12 @@ __export(browser_exports, {
11041
12103
  encodeOverlayMutation: () => encodeOverlayMutation,
11042
12104
  estimateMaterializedBytes: () => estimateMaterializedBytes,
11043
12105
  features: () => features,
12106
+ findReservedFieldNameInToml: () => findReservedFieldNameInToml,
11044
12107
  foldFormat2Overlay: () => foldFormat2Overlay,
11045
12108
  followEpoch: () => followEpoch,
11046
12109
  format2ClientDDL: () => format2ClientDDL,
11047
12110
  format2EpochDocName: () => format2EpochDocName,
12111
+ format2EpochDocPrefix: () => format2EpochDocPrefix,
11048
12112
  format2StringSetMembers: () => format2StringSetMembers,
11049
12113
  format2TableNames: () => format2TableNames,
11050
12114
  generateULID: () => generateULID,
@@ -11183,6 +12247,30 @@ var DatabaseEngine = class {
11183
12247
  * as "follows no epoch".
11184
12248
  */
11185
12249
  format2StoredEpoch;
12250
+ /**
12251
+ * Remove one large document's local data, and the store holding it when
12252
+ * nothing else is in there.
12253
+ *
12254
+ * An engine whose store lives somewhere only it can reach — the browser's
12255
+ * worker-hosted pool — implements this; engines whose SQLite is synchronous
12256
+ * and local leave it undefined and the in-process path purges through
12257
+ * `createFormat2Sql()` instead. Either way the doors an app sees
12258
+ * (`documents.evict`, `closeDocument({ evictLocal })`,
12259
+ * `logout({ wipeLocal })`) remove the same things.
12260
+ *
12261
+ * `scope` is what the store is namespaced by beside the document, passed by
12262
+ * the caller rather than remembered here: a purge at logout is about the
12263
+ * user who is signing OUT, and an engine that outlives the sign-in would
12264
+ * otherwise resolve the wrong store.
12265
+ */
12266
+ format2Purge;
12267
+ /**
12268
+ * Remove every large document's local data within one scope, except `keep`.
12269
+ *
12270
+ * Reaches documents this session never opened, and stores left behind before
12271
+ * a purge existed — neither of which has anything in memory naming it.
12272
+ */
12273
+ format2PurgeAll;
11186
12274
  /**
11187
12275
  * The engine that holds `docIds`' derived query tables for `modelName`.
11188
12276
  *
@@ -12356,6 +13444,8 @@ var Format2NamespaceAttachment = class {
12356
13444
  */
12357
13445
  hiding = false;
12358
13446
  leaderRelease = null;
13447
+ /** Set by a `retire` that succeeded: the pool's VFS is paused (#3754). */
13448
+ hasRetired = false;
12359
13449
  election = null;
12360
13450
  electionQueued = false;
12361
13451
  broker = null;
@@ -12419,6 +13509,39 @@ var Format2NamespaceAttachment = class {
12419
13509
  holders(docId) {
12420
13510
  return this.send(FORMAT2_WORKER_CHANNEL, { kind: "holders", docId });
12421
13511
  }
13512
+ /**
13513
+ * Remove `docId`'s rows from the pool's store (#3754).
13514
+ *
13515
+ * Goes out on whatever this tab is attached to, leader or follower: the
13516
+ * command touches rows, not the pool, and the worker refuses it while any
13517
+ * session of the document is registered from any port.
13518
+ */
13519
+ purge(docId, force) {
13520
+ return this.send(FORMAT2_WORKER_CHANNEL, { kind: "purge", docId, force });
13521
+ }
13522
+ /**
13523
+ * Ask the worker to give up the pool, so its directory may be removed
13524
+ * (#3754).
13525
+ *
13526
+ * Refused while any session on any document exists. On `retired: true` the
13527
+ * database is closed, the VFS paused and the pool lock released BEFORE the
13528
+ * reply, so the caller may remove the directory the moment it reads it — and
13529
+ * it still holds the leader lock while it does, which is what stops a
13530
+ * re-elected tab installing into a directory that is about to go.
13531
+ */
13532
+ async retire() {
13533
+ const reply = await this.send(FORMAT2_WORKER_CHANNEL, { kind: "retire" });
13534
+ if (reply.retired) this.hasRetired = true;
13535
+ return reply;
13536
+ }
13537
+ /** Whether this attachment's worker has given up its pool (#3754). */
13538
+ get retired() {
13539
+ return this.hasRetired;
13540
+ }
13541
+ /** Whether this tab holds the namespace's leader lock right now (#3754). */
13542
+ get leads() {
13543
+ return this.attachment?.role === "leader" && (this.options.locks === null || this.leaderRelease !== null);
13544
+ }
12422
13545
  release(docId, clientId, wipeIfLast) {
12423
13546
  return this.send(FORMAT2_WORKER_CHANNEL, {
12424
13547
  kind: "release",
@@ -12943,7 +14066,7 @@ var Format2NamespaceAttachment = class {
12943
14066
  * lifecycle step-down above never consults it, because a frozen page would
12944
14067
  * never reach the end of it.
12945
14068
  */
12946
- async destroy() {
14069
+ async destroy(options = {}) {
12947
14070
  if (this.destroyed) return;
12948
14071
  const current = this.attachment;
12949
14072
  const leaving = Array.from(this.sessions);
@@ -12960,7 +14083,9 @@ var Format2NamespaceAttachment = class {
12960
14083
  this.attachment = null;
12961
14084
  for (const session of leaving) session.detach();
12962
14085
  if (current?.role === "leader") this.sayBye();
12963
- if (current) {
14086
+ if (current && options.alreadyRetired) {
14087
+ current.handle.terminate();
14088
+ } else if (current) {
12964
14089
  try {
12965
14090
  await current.handle.close(this.options.closeBoundMs);
12966
14091
  } catch (error) {
@@ -13163,6 +14288,42 @@ function observeFormat2Lifecycle(target, handlers) {
13163
14288
  function format2ClientLockName(namespace, clientId) {
13164
14289
  return `${namespace.lockName}:client:${encodePart(clientId)}`;
13165
14290
  }
14291
+ function format2DocumentLockName(namespace, docId) {
14292
+ return `${namespace.lockName}:doc:${encodePart(docId)}`;
14293
+ }
14294
+ function format2PoolDirectory(poolName) {
14295
+ return `.${poolName}`;
14296
+ }
14297
+ function format2StoreNamespacePrefix(scope = {}) {
14298
+ const parts = [scope.server ?? "", scope.app ?? "", scope.user ?? ""];
14299
+ return parts.some((part) => part !== "") ? PREFIX + parts.map(encodePart).join("-") + "-" : PREFIX;
14300
+ }
14301
+ function format2DocumentIdFromPoolName(poolName, scope = {}) {
14302
+ const prefix = format2StoreNamespacePrefix(scope);
14303
+ if (!poolName.startsWith(prefix)) return null;
14304
+ const encoded = poolName.slice(prefix.length);
14305
+ if (encoded.length === 0 || encoded.includes("-")) return null;
14306
+ const bytes = [];
14307
+ for (let at = 0; at < encoded.length; ) {
14308
+ if (encoded[at] === "_") {
14309
+ const hex = encoded.slice(at + 1, at + 3);
14310
+ if (!/^[0-9a-f]{2}$/.test(hex)) return null;
14311
+ bytes.push(parseInt(hex, 16));
14312
+ at += 3;
14313
+ continue;
14314
+ }
14315
+ if (!/^[A-Za-z0-9]$/.test(encoded[at])) return null;
14316
+ bytes.push(encoded.charCodeAt(at));
14317
+ at += 1;
14318
+ }
14319
+ try {
14320
+ return new TextDecoder("utf-8", { fatal: true }).decode(
14321
+ new Uint8Array(bytes)
14322
+ );
14323
+ } catch {
14324
+ return null;
14325
+ }
14326
+ }
13166
14327
  var PREFIX = "js-bao-format2-";
13167
14328
  function encodePart(value) {
13168
14329
  let out = "";
@@ -13229,6 +14390,8 @@ var OpfsEngine = class {
13229
14390
  destroyed = false;
13230
14391
  /** Fires on `destroy()`: no open in progress spawns another worker. */
13231
14392
  teardown = new AbortController();
14393
+ /** The OPFS root, once something has asked for it (#3754). */
14394
+ resolvedRoot = null;
13232
14395
  constructor(options = {}, internals = {}) {
13233
14396
  this.internals = { ...options.internals ?? {}, ...internals };
13234
14397
  this.mirror = new SqljsEngine({
@@ -13328,10 +14491,18 @@ var OpfsEngine = class {
13328
14491
  */
13329
14492
  async createFormat2StoreHost(docId, options = {}) {
13330
14493
  const namespace = format2StoreNamespace(docId, options.scope ?? {});
14494
+ const documentHeld = await this.holdShared(
14495
+ format2DocumentLockName(namespace, docId)
14496
+ );
14497
+ if (documentHeld) this.presence.add(documentHeld);
14498
+ const releaseDocument = () => {
14499
+ if (documentHeld && this.presence.delete(documentHeld)) documentHeld();
14500
+ };
13331
14501
  let entry;
13332
14502
  try {
13333
14503
  entry = await this.ensureWorker(docId, options.scope);
13334
14504
  } catch (error) {
14505
+ releaseDocument();
13335
14506
  const reason = error?.message ?? String(error);
13336
14507
  Logger.error(
13337
14508
  `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
@@ -13344,6 +14515,7 @@ var OpfsEngine = class {
13344
14515
  host = entry.attachment.serveStore(docId, clientId);
13345
14516
  await host.dispatch({ kind: "storedEpoch" });
13346
14517
  } catch (error) {
14518
+ releaseDocument();
13347
14519
  const reason = error?.message ?? String(error);
13348
14520
  Logger.error(
13349
14521
  `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
@@ -13356,9 +14528,10 @@ var OpfsEngine = class {
13356
14528
  else this.docs.set(docId, { entry, sessions: 1 });
13357
14529
  const held = clientId ? this.holdPresence(format2ClientLockName(namespace, clientId)) : null;
13358
14530
  if (held) this.presence.add(held);
13359
- const releasePresence = held ? () => {
13360
- if (this.presence.delete(held)) held();
13361
- } : null;
14531
+ const releasePresence = () => {
14532
+ if (held && this.presence.delete(held)) held();
14533
+ releaseDocument();
14534
+ };
13362
14535
  const engine = this;
13363
14536
  const closeOnce = async (closeOptions) => {
13364
14537
  const routing2 = engine.docs.get(docId);
@@ -13366,7 +14539,7 @@ var OpfsEngine = class {
13366
14539
  entry.docIds.delete(docId);
13367
14540
  engine.docs.delete(docId);
13368
14541
  }
13369
- releasePresence?.();
14542
+ releasePresence();
13370
14543
  return host.close(closeOptions);
13371
14544
  };
13372
14545
  let closing = null;
@@ -13402,73 +14575,464 @@ var OpfsEngine = class {
13402
14575
  return null;
13403
14576
  }
13404
14577
  }
13405
- /** `navigator.locks`, unless a test handed one in. `null` means none. */
13406
- locks() {
13407
- if (this.internals.locks !== void 0) return this.internals.locks;
13408
- const locks = globalThis.navigator?.locks;
13409
- return locks && typeof locks.request === "function" ? locks : null;
13410
- }
14578
+ // ── Removing a document's store, and the pool holding it (#3754) ──
13411
14579
  /**
13412
- * Hold `name` until the returned function is called.
14580
+ * Remove `docId`'s local data, and its pool's directory once it holds
14581
+ * nothing else.
13413
14582
  *
13414
- * The release is SYNCHRONOUS on purpose: resolving the callback's promise is
13415
- * what frees a Web Lock, and a page being torn down gets one task to do it
13416
- * in — nothing it awaits afterwards will run.
14583
+ * The namespace is ALWAYS built from the `scope` the caller passed — there is
14584
+ * no cache of "which namespace did I last use for this document". This engine
14585
+ * outlives a sign-in, and the open path re-reads the scope every time, so a
14586
+ * remembered namespace would let user B's evict resolve user A's pool while
14587
+ * leaving B's alone (finding 3754-SO-06).
13417
14588
  *
13418
- * A release can arrive BEFORE the grant does: a lock manager queues the
13419
- * callback as a task, and a document opened and closed inside one task never
13420
- * gets there. The release is therefore remembered, not merely delivered —
13421
- * the callback resolves at once when it finally runs, and a request still
13422
- * queued is withdrawn. A presence lock left held says a client is still here
13423
- * when its writes are sitting in the log waiting to be adopted.
14589
+ * Everything below happens under the document's presence lock, taken
14590
+ * EXCLUSIVE with `ifAvailable`: it never waits, so a document another tab has
14591
+ * open is refused rather than blocking, and holding it across the data purge,
14592
+ * the retire and the removal is what stops a tab opening into a half-purged
14593
+ * store (edge E14 — its shared request simply waits).
13424
14594
  */
13425
- holdPresence(name) {
13426
- const locks = this.locks();
13427
- if (!locks) return null;
13428
- let release = null;
13429
- let done = false;
13430
- const withdraw = typeof AbortController === "function" ? new AbortController() : null;
13431
- void Promise.resolve(
13432
- locks.request(
13433
- name,
13434
- { mode: "exclusive", ...withdraw ? { signal: withdraw.signal } : {} },
13435
- () => new Promise((resolve) => {
13436
- if (done) resolve();
13437
- else release = resolve;
13438
- })
14595
+ async format2Purge(docId, options) {
14596
+ if (this.destroyed) {
14597
+ throw new DocumentClosedError(
14598
+ `[format2] The opfs engine was destroyed; '${docId}' cannot be purged.`
14599
+ );
14600
+ }
14601
+ await this.ensureRoot();
14602
+ const namespace = format2StoreNamespace(docId, options.scope ?? {});
14603
+ if (this.docs.has(docId)) {
14604
+ Logger.warn(
14605
+ `[OpfsEngine] ${docId}: purge refused: held (this tab has it open).`
14606
+ );
14607
+ return { purged: false, reason: "held" };
14608
+ }
14609
+ const taken = await this.withExclusive(
14610
+ format2DocumentLockName(namespace, docId),
14611
+ () => this.purgeUnderDocumentLock(
14612
+ docId,
14613
+ namespace,
14614
+ options.scope ?? {},
14615
+ options.force
13439
14616
  )
13440
- ).catch(() => {
13441
- });
13442
- return () => {
13443
- done = true;
13444
- release?.();
13445
- withdraw?.abort();
13446
- };
13447
- }
13448
- /** Whether somebody holds `name` right now. Never waits. */
13449
- async probeLock(name) {
13450
- const locks = this.locks();
13451
- if (!locks) return false;
13452
- try {
13453
- return await locks.request(
13454
- name,
13455
- { ifAvailable: true },
13456
- (lock) => lock === null
14617
+ );
14618
+ if (!taken.got) {
14619
+ Logger.warn(
14620
+ `[OpfsEngine] ${docId}: purge refused: held (another tab holds its presence lock, so it still has the document open).`
13457
14621
  );
13458
- } catch {
13459
- return false;
14622
+ return { purged: false, reason: "held", presence: true };
13460
14623
  }
14624
+ return taken.value;
13461
14625
  }
13462
14626
  /**
13463
- * The engine holding `docIds`' rows of `modelName` (Decision R).
13464
- *
13465
- * All ordinary → the mirror. One side only → that side. A scope spanning
13466
- * both an ordinary and a large document (or two large documents) is probed
13467
- * with one `LIMIT 1` existence query per side: exactly one side holding
13468
- * rows answers; both is refused with `Format2QueryScopeError`; neither
13469
- * answers empty from a side that HAS the model's table — the mirror only
13470
- * sees a model's table once an ordinary document connected, so two large
13471
- * documents with no rows yet are answered from one of their workers.
14627
+ * Remove every large document of one scope except `keep` (#3754).
14628
+ *
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).
14634
+ */
14635
+ async format2PurgeAll(options) {
14636
+ if (this.destroyed) {
14637
+ throw new DocumentClosedError(
14638
+ "[format2] The opfs engine was destroyed; its pools cannot be swept."
14639
+ );
14640
+ }
14641
+ await this.ensureRoot();
14642
+ 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
+ const removed = [];
14650
+ 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
+ }
14656
+ const poolName = directory.slice(1);
14657
+ const docId = format2DocumentIdFromPoolName(poolName, scope);
14658
+ if (docId === null) {
14659
+ skipped.push({ pool: directory, reason: "unrecognized" });
14660
+ continue;
14661
+ }
14662
+ const result = await this.format2Purge(docId, {
14663
+ force: options.force,
14664
+ scope
14665
+ });
14666
+ if (result.purged && result.poolRemoved) {
14667
+ removed.push(directory);
14668
+ continue;
14669
+ }
14670
+ const reason = result.purged ? "kept-pool" : result.reason;
14671
+ skipped.push({ pool: directory, reason });
14672
+ Logger.warn(
14673
+ `[OpfsEngine] the sweep skipped the pool '${directory}': ${reason}.`
14674
+ );
14675
+ }
14676
+ Logger.info(
14677
+ `[OpfsEngine] large-document sweep removed ${removed.length} pool(s), skipped ${skipped.length}.`
14678
+ );
14679
+ return { removed, skipped };
14680
+ }
14681
+ /**
14682
+ * The purge itself, with this tab holding the document lock exclusively.
14683
+ *
14684
+ * Three cases, and what they have in common is that no removal is EVER
14685
+ * decided on this engine's own bookkeeping: `entry.docIds` and
14686
+ * `routing.sessions` say what THIS tab serves, while the worker's session map
14687
+ * says what every tab does (finding 3754-SO-03).
14688
+ */
14689
+ async purgeUnderDocumentLock(docId, namespace, scope, force) {
14690
+ const existing = this.workers.get(namespace.poolName);
14691
+ const attached = existing && !existing.attachment.died ? existing : null;
14692
+ if (!attached) {
14693
+ return this.purgeWithoutAttachment(docId, namespace, scope, force);
14694
+ }
14695
+ return this.purgeThroughAttachment(docId, namespace, attached, force);
14696
+ }
14697
+ /** No attachment for the namespace: what is on disk decides what happens. */
14698
+ async purgeWithoutAttachment(docId, namespace, scope, force) {
14699
+ const directory = format2PoolDirectory(namespace.poolName);
14700
+ const root = this.opfsRoot();
14701
+ if (root) {
14702
+ const present = await this.directoryExists(root, directory);
14703
+ if (!present) {
14704
+ Logger.info(
14705
+ `[OpfsEngine] ${docId}: nothing stored locally (no pool directory).`
14706
+ );
14707
+ return { purged: true, nothingStored: true, poolRemoved: false };
14708
+ }
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
+ }
14724
+ let entry;
14725
+ try {
14726
+ entry = await this.ensureWorker(docId, scope);
14727
+ } catch (error) {
14728
+ const reason = error?.message ?? String(error);
14729
+ Logger.warn(
14730
+ `[OpfsEngine] ${docId}: purge refused: unreachable \u2014 another tab leads its pool and no port was offered (${reason}).`
14731
+ );
14732
+ return { purged: false, reason: "unreachable" };
14733
+ }
14734
+ return this.purgeThroughAttachment(docId, namespace, entry, force);
14735
+ }
14736
+ /** An attachment for the namespace: the worker decides, then the leader acts. */
14737
+ async purgeThroughAttachment(docId, namespace, entry, force) {
14738
+ let reply;
14739
+ try {
14740
+ reply = await entry.attachment.purge(docId, force);
14741
+ } catch (error) {
14742
+ const reason = error?.message ?? String(error);
14743
+ Logger.warn(
14744
+ `[OpfsEngine] ${docId}: purge refused: unreachable (${reason}).`
14745
+ );
14746
+ return { purged: false, reason: "unreachable" };
14747
+ }
14748
+ if (!reply.purged) {
14749
+ if (reply.reason === "pending") {
14750
+ Logger.warn(
14751
+ `[OpfsEngine] ${docId}: purge refused: ${reply.pendingOps ?? 0} unacknowledged write(s); purge with force to drop them.`
14752
+ );
14753
+ return {
14754
+ purged: false,
14755
+ reason: "pending",
14756
+ pendingOps: reply.pendingOps ?? 0
14757
+ };
14758
+ }
14759
+ Logger.warn(
14760
+ `[OpfsEngine] ${docId}: purge refused: held by ${reply.holders ?? 0} session(s) on the pool's worker.`
14761
+ );
14762
+ return { purged: false, reason: "held", holders: reply.holders };
14763
+ }
14764
+ Logger.info(
14765
+ `[OpfsEngine] ${docId}: local store purged (${reply.pendingOps} unacknowledged write(s) dropped, ${reply.remainingDocuments} document(s) left in the pool).`
14766
+ );
14767
+ if (reply.remainingDocuments > 0) {
14768
+ Logger.debug(
14769
+ `[OpfsEngine] ${docId}: its pool still holds ${reply.remainingDocuments} document(s), so its directory stays.`
14770
+ );
14771
+ return { ...purgedReport(reply), poolRemoved: false };
14772
+ }
14773
+ if (!entry.attachment.leads) {
14774
+ Logger.debug(
14775
+ `[OpfsEngine] ${docId}: another tab leads its pool, so its directory is left to that tab.`
14776
+ );
14777
+ return { ...purgedReport(reply), poolRemoved: false };
14778
+ }
14779
+ let retired;
14780
+ try {
14781
+ retired = await entry.attachment.retire();
14782
+ } catch (error) {
14783
+ Logger.warn(
14784
+ `[OpfsEngine] ${docId}: its pool could not be retired (${error?.message ?? String(error)}), so its directory stays.`
14785
+ );
14786
+ return { ...purgedReport(reply), poolRemoved: false };
14787
+ }
14788
+ if (!retired.retired) {
14789
+ Logger.warn(
14790
+ `[OpfsEngine] ${docId}: its pool was not retired \u2014 ${retired.holders} session(s) on its worker still hold a document open, so its directory stays.`
14791
+ );
14792
+ return { ...purgedReport(reply), poolRemoved: false };
14793
+ }
14794
+ const poolRemoved = await this.removePoolDirectory(namespace, docId, entry);
14795
+ this.workers.delete(namespace.poolName);
14796
+ this.attachments.delete(entry.attachment);
14797
+ await entry.attachment.destroy({ alreadyRetired: true }).catch(() => {
14798
+ });
14799
+ return { ...purgedReport(reply), poolRemoved };
14800
+ }
14801
+ /**
14802
+ * Remove one pool's OPFS directory. The ONLY caller of `removeEntry`.
14803
+ *
14804
+ * Refuses unless this tab holds the namespace's leader lock and the
14805
+ * document's presence lock exclusively, and unless the namespace has no
14806
+ * attachment still serving it — a directory whose sync access handles are
14807
+ * still held cannot be removed anyway (`NoModificationAllowedError`,
14808
+ * measured), and one that could be would be taken from under a tab that is
14809
+ * reading it.
14810
+ *
14811
+ * Answers whether the directory went; a failure is a warn and a `false`, not
14812
+ * a throw, because the DATA is already gone by this point and a caller that
14813
+ * treated the whole purge as failed would report it wrongly.
14814
+ */
14815
+ async removePoolDirectory(namespace, docId, entry) {
14816
+ const directory = format2PoolDirectory(namespace.poolName);
14817
+ if (this.destroyed) {
14818
+ Logger.warn(
14819
+ `[OpfsEngine] ${docId}: the engine was destroyed while its purge was in flight, so '${directory}' is left on disk.`
14820
+ );
14821
+ return false;
14822
+ }
14823
+ const root = this.opfsRoot();
14824
+ if (!root) {
14825
+ Logger.debug(
14826
+ `[OpfsEngine] ${docId}: no OPFS root is configured, so '${directory}' is left on disk; its rows are gone.`
14827
+ );
14828
+ return false;
14829
+ }
14830
+ if (entry && !entry.attachment.leads) {
14831
+ Logger.warn(
14832
+ `[OpfsEngine] ${docId}: this tab no longer leads its pool, so '${directory}' is left on disk; its rows are gone.`
14833
+ );
14834
+ return false;
14835
+ }
14836
+ if (entry && !entry.attachment.retired) {
14837
+ Logger.warn(
14838
+ `[OpfsEngine] ${docId}: its worker has not paused its VFS, so '${directory}' is left on disk.`
14839
+ );
14840
+ return false;
14841
+ }
14842
+ try {
14843
+ await root.removeEntry(directory, { recursive: true });
14844
+ Logger.info(
14845
+ `[OpfsEngine] ${docId}: removed its pool directory '${directory}'.`
14846
+ );
14847
+ return true;
14848
+ } catch (error) {
14849
+ Logger.warn(
14850
+ `[OpfsEngine] ${docId}: could not remove its pool directory '${directory}': ${error?.message ?? String(error)}`
14851
+ );
14852
+ return false;
14853
+ }
14854
+ }
14855
+ /** `navigator.storage.getDirectory()`'s handle, or `null` where there is none. */
14856
+ opfsRoot() {
14857
+ if (this.internals.opfsRoot !== void 0) return this.internals.opfsRoot;
14858
+ return this.resolvedRoot;
14859
+ }
14860
+ /**
14861
+ * Resolve `navigator.storage.getDirectory()` once, if this platform has it.
14862
+ *
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.
14867
+ */
14868
+ async ensureRoot() {
14869
+ if (this.internals.opfsRoot !== void 0 || this.resolvedRoot) return;
14870
+ const storage = globalThis.navigator?.storage;
14871
+ if (!storage || typeof storage.getDirectory !== "function") return;
14872
+ try {
14873
+ this.resolvedRoot = await storage.getDirectory();
14874
+ } catch (error) {
14875
+ Logger.debug(
14876
+ `[OpfsEngine] the OPFS root could not be opened: ${error?.message ?? String(error)}`
14877
+ );
14878
+ }
14879
+ }
14880
+ /** Whether `name` is a directory of the root, without creating it. */
14881
+ async directoryExists(root, name) {
14882
+ try {
14883
+ await root.getDirectoryHandle(name, { create: false });
14884
+ return true;
14885
+ } catch {
14886
+ return false;
14887
+ }
14888
+ }
14889
+ /** Every directory of the root beginning with `prefix`. */
14890
+ async listPoolDirectories(prefix) {
14891
+ const root = this.opfsRoot();
14892
+ if (!root) {
14893
+ Logger.debug(
14894
+ "[OpfsEngine] no OPFS root is configured, so no pool directory can be listed or removed."
14895
+ );
14896
+ return [];
14897
+ }
14898
+ const found = [];
14899
+ try {
14900
+ for await (const name of root.keys()) {
14901
+ if (name.startsWith(prefix)) found.push(name);
14902
+ }
14903
+ } catch (error) {
14904
+ Logger.warn(
14905
+ `[OpfsEngine] the OPFS root could not be listed: ${error?.message ?? String(error)}`
14906
+ );
14907
+ }
14908
+ return found;
14909
+ }
14910
+ /**
14911
+ * Hold `name` exclusively for the length of `run`, or say it could not be.
14912
+ *
14913
+ * `ifAvailable`, so it never waits: a purge that queued behind a tab with the
14914
+ * document open would sit there for as long as that tab lives.
14915
+ */
14916
+ async withExclusive(name, run2) {
14917
+ const locks = this.locks();
14918
+ if (!locks) return { got: true, value: await run2() };
14919
+ let outcome = { got: false };
14920
+ let failure = null;
14921
+ await Promise.resolve(
14922
+ locks.request(name, { mode: "exclusive", ifAvailable: true }, async (lock) => {
14923
+ if (lock === null) return void 0;
14924
+ try {
14925
+ outcome = { got: true, value: await run2() };
14926
+ } catch (error) {
14927
+ outcome = { got: true };
14928
+ failure = error;
14929
+ }
14930
+ return void 0;
14931
+ })
14932
+ ).catch((error) => {
14933
+ Logger.warn(
14934
+ `[OpfsEngine] the lock '${name}' could not be taken: ${error?.message ?? String(error)}`
14935
+ );
14936
+ });
14937
+ if (failure) throw failure;
14938
+ return outcome;
14939
+ }
14940
+ /** `navigator.locks`, unless a test handed one in. `null` means none. */
14941
+ locks() {
14942
+ if (this.internals.locks !== void 0) return this.internals.locks;
14943
+ const locks = globalThis.navigator?.locks;
14944
+ return locks && typeof locks.request === "function" ? locks : null;
14945
+ }
14946
+ /**
14947
+ * Hold `name` until the returned function is called.
14948
+ *
14949
+ * The release is SYNCHRONOUS on purpose: resolving the callback's promise is
14950
+ * what frees a Web Lock, and a page being torn down gets one task to do it
14951
+ * in — nothing it awaits afterwards will run.
14952
+ *
14953
+ * A release can arrive BEFORE the grant does: a lock manager queues the
14954
+ * callback as a task, and a document opened and closed inside one task never
14955
+ * gets there. The release is therefore remembered, not merely delivered —
14956
+ * the callback resolves at once when it finally runs, and a request still
14957
+ * queued is withdrawn. A presence lock left held says a client is still here
14958
+ * when its writes are sitting in the log waiting to be adopted.
14959
+ */
14960
+ holdPresence(name, mode = "exclusive") {
14961
+ const locks = this.locks();
14962
+ if (!locks) return null;
14963
+ let release = null;
14964
+ let done = false;
14965
+ const withdraw = typeof AbortController === "function" ? new AbortController() : null;
14966
+ void Promise.resolve(
14967
+ locks.request(
14968
+ name,
14969
+ { mode, ...withdraw ? { signal: withdraw.signal } : {} },
14970
+ () => new Promise((resolve) => {
14971
+ if (done) resolve();
14972
+ else release = resolve;
14973
+ })
14974
+ )
14975
+ ).catch(() => {
14976
+ });
14977
+ return () => {
14978
+ done = true;
14979
+ release?.();
14980
+ withdraw?.abort();
14981
+ };
14982
+ }
14983
+ /**
14984
+ * Hold `name` SHARED, waiting for the grant, and answer the release (#3754).
14985
+ *
14986
+ * Unlike {@link holdPresence}, which fires the request off and returns at
14987
+ * once, this waits: the caller's next act is to open a pool, and a purge that
14988
+ * holds this name exclusively is in the middle of emptying that pool. Waiting
14989
+ * here is what turns a race into an ordering.
14990
+ */
14991
+ async holdShared(name) {
14992
+ const locks = this.locks();
14993
+ if (!locks) return null;
14994
+ let release = null;
14995
+ let done = false;
14996
+ await new Promise((granted) => {
14997
+ void Promise.resolve(
14998
+ locks.request(name, { mode: "shared" }, () => {
14999
+ granted();
15000
+ return new Promise((resolve) => {
15001
+ if (done) resolve();
15002
+ else release = resolve;
15003
+ });
15004
+ })
15005
+ ).catch(() => granted());
15006
+ });
15007
+ return () => {
15008
+ done = true;
15009
+ release?.();
15010
+ };
15011
+ }
15012
+ /** Whether somebody holds `name` right now. Never waits. */
15013
+ async probeLock(name) {
15014
+ const locks = this.locks();
15015
+ if (!locks) return false;
15016
+ try {
15017
+ return await locks.request(
15018
+ name,
15019
+ { ifAvailable: true },
15020
+ (lock) => lock === null
15021
+ );
15022
+ } catch {
15023
+ return false;
15024
+ }
15025
+ }
15026
+ /**
15027
+ * The engine holding `docIds`' rows of `modelName` (Decision R).
15028
+ *
15029
+ * All ordinary → the mirror. One side only → that side. A scope spanning
15030
+ * both an ordinary and a large document (or two large documents) is probed
15031
+ * with one `LIMIT 1` existence query per side: exactly one side holding
15032
+ * rows answers; both is refused with `Format2QueryScopeError`; neither
15033
+ * answers empty from a side that HAS the model's table — the mirror only
15034
+ * sees a model's table once an ordinary document connected, so two large
15035
+ * documents with no rows yet are answered from one of their workers.
13472
15036
  *
13473
15037
  * A document whose worker died is refused with `DocumentClosedError`, the
13474
15038
  * error its store already raises: the rows exist and nobody serves them.
@@ -13616,6 +15180,14 @@ var OpfsEngine = class {
13616
15180
  });
13617
15181
  }
13618
15182
  };
15183
+ function purgedReport(reply) {
15184
+ return {
15185
+ purged: true,
15186
+ ...reply.nothingStored ? { nothingStored: true } : {},
15187
+ pendingOps: reply.pendingOps,
15188
+ remainingDocuments: reply.remainingDocuments
15189
+ };
15190
+ }
13619
15191
  function withinBound2(promise, boundMs) {
13620
15192
  let timer;
13621
15193
  const bound = new Promise((resolve) => {
@@ -14044,6 +15616,22 @@ function sameOverlayValue(a, b) {
14044
15616
  if (a === null || a === void 0) return b === null || b === void 0;
14045
15617
  return JSON.stringify(a) === JSON.stringify(b);
14046
15618
  }
15619
+ function decideDeferredReplay(note, epoch, ackedSeq) {
15620
+ if (!note || note.kind !== "ordinary") return { kind: "none" };
15621
+ let resolved = note;
15622
+ let clamped = false;
15623
+ if (note.toEpoch > epoch) {
15624
+ if (note.fromEpoch >= epoch) {
15625
+ return { kind: "clear", reason: "never-completed", note };
15626
+ }
15627
+ resolved = { ...note, toEpoch: epoch };
15628
+ clamped = true;
15629
+ }
15630
+ if (resolved.throughSeq <= ackedSeq) {
15631
+ return { kind: "clear", reason: "acknowledged", note: resolved };
15632
+ }
15633
+ return { kind: "honour", note: resolved, clamped };
15634
+ }
14047
15635
  function restorePendingOverlay(doc, store, verdicts, options = {}) {
14048
15636
  const result = {
14049
15637
  restored: [],
@@ -14051,9 +15639,10 @@ function restorePendingOverlay(doc, store, verdicts, options = {}) {
14051
15639
  deferred: []
14052
15640
  };
14053
15641
  const defer = options.deferAtOrBelowEpoch ?? 0;
15642
+ const deferThrough = options.deferThroughSeq ?? 0;
14054
15643
  const missing = [];
14055
15644
  for (const op of store.pendingOps()) {
14056
- if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer) {
15645
+ if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer || deferThrough > 0 && op.seq <= deferThrough) {
14057
15646
  result.deferred.push(op.seq);
14058
15647
  continue;
14059
15648
  }
@@ -14087,17 +15676,71 @@ function statePendingOps(doc, ops, options = {}) {
14087
15676
  (entry) => `${entry.model} ${entry.recordId}`
14088
15677
  )
14089
15678
  );
14090
- const stated = [...ops].sort((a, b) => a.seq - b.seq).filter(
14091
- (op) => op.mutation !== null && !(op.op === "delete" && suppressed.has(`${op.model} ${op.recordId}`))
14092
- );
15679
+ const owned = /* @__PURE__ */ new Map();
15680
+ const laterKeys = /* @__PURE__ */ new Map();
15681
+ const lifecycle = /* @__PURE__ */ new Map();
15682
+ for (const op of [...options.later ?? []].sort((a, b) => a.seq - b.seq)) {
15683
+ const key = `${op.model} ${op.recordId}`;
15684
+ let fields = owned.get(key);
15685
+ if (!fields) owned.set(key, fields = /* @__PURE__ */ new Set());
15686
+ for (const field of op.fields) fields.add(field);
15687
+ let keys = laterKeys.get(key);
15688
+ if (!keys) laterKeys.set(key, keys = /* @__PURE__ */ new Set());
15689
+ for (const [written] of op.mutation ? encodeOverlayMutation(op.mutation) : []) {
15690
+ keys.add(written);
15691
+ }
15692
+ lifecycle.set(key, op.op);
15693
+ }
15694
+ const stated = [];
15695
+ for (const op of [...ops].sort((a, b) => a.seq - b.seq)) {
15696
+ if (op.mutation === null) continue;
15697
+ const key = `${op.model} ${op.recordId}`;
15698
+ const now = lifecycle.get(key);
15699
+ if (op.op === "delete") {
15700
+ if (suppressed.has(key) || now !== void 0 && now !== "delete") {
15701
+ continue;
15702
+ }
15703
+ stated.push({ op, mutation: op.mutation });
15704
+ continue;
15705
+ }
15706
+ if (now === "delete") {
15707
+ continue;
15708
+ }
15709
+ const later = owned.get(key);
15710
+ if (later === void 0) {
15711
+ stated.push({ op, mutation: op.mutation });
15712
+ continue;
15713
+ }
15714
+ const narrowed = withoutFields(op.mutation, later);
15715
+ if (narrowed === null) continue;
15716
+ stated.push({ op, mutation: narrowed, keep: laterKeys.get(key) });
15717
+ }
14093
15718
  if (stated.length === 0) return 0;
14094
15719
  doc.transact(() => {
14095
- for (const op of stated) {
14096
- applyOverlayMutation(doc.getMap(op.model), op.mutation);
15720
+ for (const { op, mutation, keep } of stated) {
15721
+ applyOverlayMutation(doc.getMap(op.model), mutation, { keep });
14097
15722
  }
14098
15723
  });
14099
15724
  return stated.length;
14100
15725
  }
15726
+ function withoutFields(mutation, owned) {
15727
+ const fields = Object.fromEntries(
15728
+ Object.entries(mutation.fields ?? {}).filter(([field]) => !owned.has(field))
15729
+ );
15730
+ const deltas = Object.fromEntries(
15731
+ Object.entries(mutation.stringSetDeltas ?? {}).filter(
15732
+ ([field]) => !owned.has(field)
15733
+ )
15734
+ );
15735
+ if (mutation.kind !== "create" && Object.keys(fields).length === 0 && Object.keys(deltas).length === 0) {
15736
+ return null;
15737
+ }
15738
+ return {
15739
+ ...mutation,
15740
+ ...mutation.fields === void 0 ? {} : { fields },
15741
+ ...mutation.stringSetDeltas === void 0 ? {} : { stringSetDeltas: deltas }
15742
+ };
15743
+ }
14101
15744
 
14102
15745
  // src/models/format2SnapshotLoader.ts
14103
15746
  init_snapshotChunks();
@@ -14411,17 +16054,21 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14411
16054
  const crossed = store.discontinuityEpochs();
14412
16055
  const boundary = crossed.length > 0 ? crossed[crossed.length - 1] : 0;
14413
16056
  const deferAtOrBelowEpoch = boundary > 0 && store.epoch() > boundary ? boundary : 0;
14414
- const restored = restorePendingOverlay(
14415
- yDoc,
14416
- store,
14417
- verdicts,
14418
- deferAtOrBelowEpoch > 0 ? { deferAtOrBelowEpoch } : {}
14419
- );
14420
- if (restored.deferred.length > 0) {
16057
+ const owedJudgement = await this.settleDeferredReplay(docId, store);
16058
+ const restored = restorePendingOverlay(yDoc, store, verdicts, {
16059
+ ...deferAtOrBelowEpoch > 0 ? { deferAtOrBelowEpoch } : {},
16060
+ ...owedJudgement ? { deferThroughSeq: owedJudgement.throughSeq } : {}
16061
+ });
16062
+ if (restored.deferred.length > 0 && deferAtOrBelowEpoch > 0) {
14421
16063
  Logger.warn(
14422
16064
  `[DocumentManager] ${docId} is holding ${restored.deferred.length} unacknowledged write(s) off its overlay until it has converged on the bulk load at epoch ${deferAtOrBelowEpoch}.`
14423
16065
  );
14424
16066
  }
16067
+ if (restored.deferred.length > 0 && owedJudgement) {
16068
+ Logger.warn(
16069
+ `[format2] ${docId} is holding ${restored.deferred.length} unacknowledged write(s) off its overlay until the deferred judgement of epochs ${owedJudgement.fromEpoch}..${owedJudgement.toEpoch} has run.`
16070
+ );
16071
+ }
14425
16072
  const adoptedWasRestored = verdicts !== void 0 && restored.restored.some((seq) => verdicts.has(seq));
14426
16073
  if (adoptedWasRestored) {
14427
16074
  foldFormat2Overlay(
@@ -14445,6 +16092,47 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14445
16092
  store.onReattached(() => this.revalidateQueryTables(docId, store))
14446
16093
  );
14447
16094
  }
16095
+ /**
16096
+ * Settle what a deferred judgement means for THIS bind (#3755).
16097
+ *
16098
+ * Returns the note whose covered writes must stay off the overlay, or `null`
16099
+ * when there is nothing to honour. The decision is
16100
+ * {@link decideDeferredReplay}'s, so the bind and the resumed judgement read
16101
+ * one note the same way.
16102
+ *
16103
+ * The clamp is deliberately NOT written back: `noteDeferredReplay` widens,
16104
+ * so it could not narrow a target, and the decision is a pure function of the
16105
+ * row and the store's epoch — every later reader reaches the same answer. A
16106
+ * note that is CLEARED is cleared durably, because that is a fact about the
16107
+ * debt rather than a reading of it.
16108
+ */
16109
+ async settleDeferredReplay(docId, store) {
16110
+ const decision = decideDeferredReplay(
16111
+ store.deferredReplay(),
16112
+ store.epoch(),
16113
+ store.ackedSeq()
16114
+ );
16115
+ if (decision.kind === "none") return null;
16116
+ if (decision.kind === "clear") {
16117
+ if (decision.reason === "acknowledged") {
16118
+ Logger.debug(
16119
+ `[format2] ${docId}: the deferred judgement of epochs ${decision.note.fromEpoch}..${decision.note.toEpoch} is settled: every covered write was acknowledged through sequence ${decision.note.throughSeq}.`
16120
+ );
16121
+ } else {
16122
+ Logger.warn(
16123
+ `[format2] ${docId}: a deferred-replay note names epoch ${decision.note.toEpoch}, but this store is on ${store.epoch()} and nothing was deferred below it; the move never completed, restoring as usual.`
16124
+ );
16125
+ }
16126
+ await store.clearDeferredReplay();
16127
+ return null;
16128
+ }
16129
+ if (decision.clamped) {
16130
+ Logger.debug(
16131
+ `[format2] ${docId}: a later deferred move was interrupted, so the judgement this bind owes is clamped to the epoch this store reached: ${store.deferredReplay()?.fromEpoch}..${store.deferredReplay()?.toEpoch} \u2192 ${decision.note.fromEpoch}..${decision.note.toEpoch}.`
16132
+ );
16133
+ }
16134
+ return decision.note;
16135
+ }
14448
16136
  /**
14449
16137
  * Rebuild whatever the query tables lost while this tab was off the worker
14450
16138
  * (#3431, finding 3431-R12).
@@ -14664,6 +16352,31 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14664
16352
  * hydration scope; an uncapped one answers with the models this instance
14665
16353
  * registered, which is every model whose rows could be here.
14666
16354
  */
16355
+ /**
16356
+ * Hydrate ONE model's derived query tables for a bound large document
16357
+ * (#3615).
16358
+ *
16359
+ * What a model registered on a running instance needs: the merged view
16360
+ * already holds its rows — the fold and the overlay observer are keyed by
16361
+ * the model name the overlay carries, not by the registry — but the derived
16362
+ * tables `query()` reads were never built, because the model was not in the
16363
+ * registry when the document was connected.
16364
+ *
16365
+ * Forced, because the point is a rebuild: whatever mark a previous session
16366
+ * left says nothing about tables this instance has never projected into.
16367
+ */
16368
+ async hydrateModel(docId, modelName) {
16369
+ const store = this.opened.get(docId);
16370
+ if (!store) return;
16371
+ if (!store.isHydrated(modelName)) {
16372
+ Logger.warn(
16373
+ `[DocumentManager] Large document ${docId} does not hold model '${modelName}' on this device (its hydration scope excludes it), so the query tables for '${modelName}' were not hydrated. Reads of '${modelName}' on ${docId} answer as they do for any model this device declined.`
16374
+ );
16375
+ return;
16376
+ }
16377
+ await this.hydrateQueryTables(docId, store, { only: modelName });
16378
+ await this.whenProjectionSettled();
16379
+ }
14667
16380
  localModels(docId) {
14668
16381
  const store = this.ormContext.getFormat2StoreAsync(docId);
14669
16382
  if (!store) return null;
@@ -15063,6 +16776,8 @@ function withinBound3(promise, boundMs) {
15063
16776
 
15064
16777
  // src/models/format2Sync.ts
15065
16778
  init_OrmContext();
16779
+ init_format2RecordStore();
16780
+ init_logger();
15066
16781
  function format2StoredEpoch(engine, docId, ctx) {
15067
16782
  if (!docId) return null;
15068
16783
  const bound = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
@@ -15099,6 +16814,82 @@ async function format2StoredEpochAsync(engine, docId, ctx, scope) {
15099
16814
  }
15100
16815
  return format2StoredEpoch(engine, docId, ctx);
15101
16816
  }
16817
+ async function format2Purge(engine, docId, options, ctx) {
16818
+ if (!docId) return { purged: false, reason: "held" };
16819
+ const bound = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
16820
+ if (bound) {
16821
+ Logger.warn(
16822
+ `[format2] ${docId}: its local store is open in this instance, so it was not purged; close the document first.`
16823
+ );
16824
+ return { purged: false, reason: "held" };
16825
+ }
16826
+ const force = options.force === true;
16827
+ if (engine?.format2Purge) {
16828
+ return engine.format2Purge(docId, { force, scope: options.scope });
16829
+ }
16830
+ const sql = engine?.createFormat2Sql?.();
16831
+ if (!sql) {
16832
+ return { purged: true, nothingStored: true, pendingOps: 0, remainingDocuments: 0 };
16833
+ }
16834
+ return purgeThroughStore(sql, docId, force);
16835
+ }
16836
+ function purgeThroughStore(sql, docId, force) {
16837
+ return sql.transaction(() => {
16838
+ const pendingOps = Format2RecordStore.pendingOpCount(sql, docId);
16839
+ if (!force && pendingOps > 0) {
16840
+ Logger.warn(
16841
+ `[format2] ${docId}: not purged \u2014 its local store holds ${pendingOps} unacknowledged write(s). Purge with force to drop them.`
16842
+ );
16843
+ return { purged: false, reason: "pending", pendingOps };
16844
+ }
16845
+ const report = Format2RecordStore.purge(sql, docId);
16846
+ Logger.info(
16847
+ `[format2] ${docId}: local store purged (${report.pendingOps} unacknowledged write(s) dropped, ${report.remainingDocuments} document(s) left in the store).`
16848
+ );
16849
+ return {
16850
+ purged: true,
16851
+ ...report.nothingStored ? { nothingStored: true } : {},
16852
+ pendingOps: report.pendingOps,
16853
+ remainingDocuments: report.remainingDocuments
16854
+ };
16855
+ });
16856
+ }
16857
+ async function format2PurgeAll(engine, options, ctx) {
16858
+ const force = options.force === true;
16859
+ if (engine?.format2PurgeAll) {
16860
+ return engine.format2PurgeAll({
16861
+ ...options.keep ? { keep: options.keep } : {},
16862
+ force,
16863
+ scope: options.scope
16864
+ });
16865
+ }
16866
+ const sql = engine?.createFormat2Sql?.();
16867
+ if (!sql) return { removed: [], skipped: [], purged: [] };
16868
+ const context = ctx ?? getDefaultOrmContext();
16869
+ const keep = new Set(options.keep ?? []);
16870
+ const purged = [];
16871
+ const skipped = [];
16872
+ for (const docId of Format2RecordStore.storedDocuments(sql)) {
16873
+ if (keep.has(docId)) {
16874
+ skipped.push({ pool: docId, reason: "keep" });
16875
+ continue;
16876
+ }
16877
+ if (context.getFormat2StoreAsync(docId)) {
16878
+ Logger.warn(
16879
+ `[format2] ${docId}: skipped by the local-store sweep \u2014 it is open in this instance.`
16880
+ );
16881
+ skipped.push({ pool: docId, reason: "held" });
16882
+ continue;
16883
+ }
16884
+ const result = purgeThroughStore(sql, docId, force);
16885
+ if (result.purged) purged.push(docId);
16886
+ else skipped.push({ pool: docId, reason: result.reason });
16887
+ }
16888
+ Logger.info(
16889
+ `[format2] local-store sweep purged ${purged.length} document(s), skipped ${skipped.length}.`
16890
+ );
16891
+ return { removed: [], skipped, purged };
16892
+ }
15102
16893
  function format2SyncMarks(docId, ctx) {
15103
16894
  const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15104
16895
  if (!store) return null;
@@ -15108,7 +16899,8 @@ function format2SyncMarks(docId, ctx) {
15108
16899
  epoch: store.epoch(),
15109
16900
  clockOffsetKnown: store.clockOffsetKnown(),
15110
16901
  discontinuityEpochs: store.discontinuityEpochs(),
15111
- baseComplete: store.baseComplete()
16902
+ baseComplete: store.baseComplete(),
16903
+ deferredReplay: store.deferredReplay()
15112
16904
  };
15113
16905
  }
15114
16906
  function format2NoteEpoch(docId, epoch, ctx) {
@@ -15120,11 +16912,21 @@ function format2NoteEpoch(docId, epoch, ctx) {
15120
16912
  function format2PendingOps(docId, ctx) {
15121
16913
  return (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId)?.pendingOps() ?? [];
15122
16914
  }
15123
- function format2SetEpoch(docId, epoch, ctx) {
16915
+ function format2SetEpoch(docId, epoch, ctx, deferredReplay) {
15124
16916
  const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15125
16917
  if (!store) return;
15126
16918
  if (!Number.isInteger(epoch) || epoch <= 0) return;
15127
- store.setEpoch(epoch);
16919
+ store.setEpoch(epoch, deferredReplay ? { deferredReplay } : {});
16920
+ }
16921
+ async function format2NoteDeferredReplay(docId, note, ctx) {
16922
+ const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
16923
+ if (!store) return;
16924
+ await store.noteDeferredReplay(note);
16925
+ }
16926
+ async function format2ClearDeferredReplay(docId, ctx) {
16927
+ const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
16928
+ if (!store) return;
16929
+ await store.clearDeferredReplay();
15128
16930
  }
15129
16931
  function format2HoldWrites(docId, run2, ctx) {
15130
16932
  return (ctx ?? getDefaultOrmContext()).holdFormat2Writes(docId, run2);
@@ -15199,12 +17001,70 @@ var DocumentManagerImpl = class {
15199
17001
  modelRegistry;
15200
17002
  /** The ORM instance this manager belongs to (#3039). */
15201
17003
  ormContext;
17004
+ /**
17005
+ * The registration gate (#3615).
17006
+ *
17007
+ * `connectDocument` finishes its model loop BEFORE it publishes the document
17008
+ * in `connectedDocuments`, so a registration that read the map in that
17009
+ * interval would miss the document while its own loop had already run —
17010
+ * leaving it connected with no table, no rows and no observer for the new
17011
+ * model. Registrations therefore run one at a time on this chain; a connect,
17012
+ * a rebind and a disconnect wait for it at entry, and a registration waits
17013
+ * for the connects that got in before it ({@link activeConnects}) before it
17014
+ * reads the map. Connects of different documents that no registration
17015
+ * overlaps still run concurrently, as they always have.
17016
+ */
17017
+ registrationChain = Promise.resolve();
17018
+ /** Connects past the gate and not yet published — what a registration waits for. */
17019
+ activeConnects = /* @__PURE__ */ new Set();
17020
+ /**
17021
+ * The `(model, document)` pairs whose initialization for this instance
17022
+ * COMPLETED (#3615).
17023
+ *
17024
+ * Registry membership alone cannot say it: a document-level failure leaves
17025
+ * the class registered but that document unusable, and `initializeForDocument`
17026
+ * clears a document's rows and installs a fresh observer on every run, so a
17027
+ * blanket retry would wipe the documents that had succeeded and double their
17028
+ * observers. A retry resumes with the pairs that are not in here.
17029
+ */
17030
+ runtimeInitialized = /* @__PURE__ */ new Map();
17031
+ /**
17032
+ * Pass the registration gate, then run `work` as an in-flight connect
17033
+ * (#3615).
17034
+ *
17035
+ * The re-read after the await is what closes the window: a registration
17036
+ * publishes its chain synchronously when it is called, so a caller that
17037
+ * waited on the chain it saw first has to check whether another one was
17038
+ * queued behind it. Joining {@link activeConnects} happens with no await in
17039
+ * between, so nothing can slip between the check and the join.
17040
+ */
17041
+ async enterConnect(work) {
17042
+ for (; ; ) {
17043
+ const chain = this.registrationChain;
17044
+ await chain;
17045
+ if (this.registrationChain === chain) break;
17046
+ }
17047
+ let settle;
17048
+ const ticket = new Promise((resolve) => settle = resolve);
17049
+ this.activeConnects.add(ticket);
17050
+ try {
17051
+ return await work();
17052
+ } finally {
17053
+ this.activeConnects.delete(ticket);
17054
+ settle();
17055
+ }
17056
+ }
15202
17057
  async connectDocument(docId, yDoc, permissionHint, options) {
17058
+ return this.enterConnect(
17059
+ () => this.connectDocumentInner(docId, yDoc, permissionHint, options)
17060
+ );
17061
+ }
17062
+ async connectDocumentInner(docId, yDoc, permissionHint, options) {
15203
17063
  if (this.connectedDocuments.has(docId)) {
15204
17064
  console.warn(
15205
17065
  `[DocumentManager] Document ${docId} is already connected. Disconnecting old version.`
15206
17066
  );
15207
- await this.disconnectDocument(docId);
17067
+ await this.disconnectDocumentInner(docId);
15208
17068
  }
15209
17069
  const documentFormat = options?.documentFormat ?? 1;
15210
17070
  const connectedDoc = {
@@ -15231,6 +17091,9 @@ var DocumentManagerImpl = class {
15231
17091
  await this.format2.connect(docId, yDoc, options?.epoch);
15232
17092
  }
15233
17093
  this.connectedDocuments.set(docId, connectedDoc);
17094
+ for (const modelName of this.modelRegistry.getActiveModels().keys()) {
17095
+ this.markRuntimeInitialized(modelName, docId);
17096
+ }
15234
17097
  this.notifyConnectionChange({
15235
17098
  type: "connect",
15236
17099
  docId,
@@ -15249,6 +17112,11 @@ var DocumentManagerImpl = class {
15249
17112
  * models read and write through is exchanged.
15250
17113
  */
15251
17114
  async rebindEpochDocument(docId, yDoc, options = {}) {
17115
+ return this.enterConnect(
17116
+ () => this.rebindEpochDocumentInner(docId, yDoc, options)
17117
+ );
17118
+ }
17119
+ async rebindEpochDocumentInner(docId, yDoc, options = {}) {
15252
17120
  const connected = this.connectedDocuments.get(docId);
15253
17121
  if (!connected) {
15254
17122
  throw new Error(
@@ -15363,6 +17231,9 @@ var DocumentManagerImpl = class {
15363
17231
  return await this.format2.recordPresence(docId, records);
15364
17232
  }
15365
17233
  async disconnectDocument(docId) {
17234
+ return this.enterConnect(() => this.disconnectDocumentInner(docId));
17235
+ }
17236
+ async disconnectDocumentInner(docId) {
15366
17237
  const connectedDoc = this.connectedDocuments.get(docId);
15367
17238
  if (!connectedDoc) {
15368
17239
  Logger.warn(`[DocumentManager] Document ${docId} is not connected`);
@@ -15379,6 +17250,9 @@ var DocumentManagerImpl = class {
15379
17250
  wipeModels ? { wipeIfLastModels: wipeModels } : {}
15380
17251
  );
15381
17252
  this.connectedDocuments.delete(docId);
17253
+ for (const docIds of this.runtimeInitialized.values()) {
17254
+ docIds.delete(docId);
17255
+ }
15382
17256
  this.ormContext.disposeObserversForDocument(docId);
15383
17257
  this.ormContext.clearMappingsForDocId(docId);
15384
17258
  this.notifyConnectionChange({
@@ -15389,10 +17263,150 @@ var DocumentManagerImpl = class {
15389
17263
  `[DocumentManager] Document ${docId} disconnected successfully`
15390
17264
  );
15391
17265
  }
17266
+ markRuntimeInitialized(modelName, docId) {
17267
+ let docIds = this.runtimeInitialized.get(modelName);
17268
+ if (!docIds) {
17269
+ docIds = /* @__PURE__ */ new Set();
17270
+ this.runtimeInitialized.set(modelName, docIds);
17271
+ }
17272
+ docIds.add(docId);
17273
+ }
17274
+ /**
17275
+ * Add a model to this running instance (#3615). See
17276
+ * {@link InitJsBaoResult.registerModel}.
17277
+ */
17278
+ async registerModel(modelClass) {
17279
+ const previous = this.registrationChain;
17280
+ let release;
17281
+ this.registrationChain = new Promise((resolve) => release = resolve);
17282
+ try {
17283
+ await previous;
17284
+ while (this.activeConnects.size > 0) {
17285
+ await Promise.all(Array.from(this.activeConnects));
17286
+ }
17287
+ await this.runRegistration(modelClass);
17288
+ } finally {
17289
+ release();
17290
+ }
17291
+ }
17292
+ async runRegistration(modelClass) {
17293
+ if (this.ormContext.destroyed) {
17294
+ throw new Error(
17295
+ `[initJsBao] Cannot register model on destroyed js-bao instance #${this.ormContext.instanceId}. Create a new instance with initJsBao.`
17296
+ );
17297
+ }
17298
+ const modelName = this.modelRegistry.assertAdoptable(
17299
+ modelClass,
17300
+ this.ormContext.instanceId
17301
+ );
17302
+ const boundTo = modelClass?._ormContext;
17303
+ if (boundTo && boundTo !== this.ormContext && !boundTo.destroyed) {
17304
+ throw new Error(
17305
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${boundTo.instanceId}. Give instance #${this.ormContext.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
17306
+ );
17307
+ }
17308
+ const { added } = this.modelRegistry.adoptModel(modelClass, {
17309
+ instanceId: this.ormContext.instanceId
17310
+ });
17311
+ bindModelClass(this.ormContext, modelName, modelClass, "throw");
17312
+ let didDocumentWork = false;
17313
+ for (const connected of Array.from(this.connectedDocuments.values())) {
17314
+ if (this.runtimeInitialized.get(modelName)?.has(connected.docId)) {
17315
+ continue;
17316
+ }
17317
+ didDocumentWork = true;
17318
+ try {
17319
+ await this.initializeModelForConnectedDocument(
17320
+ modelClass,
17321
+ modelName,
17322
+ connected
17323
+ );
17324
+ } catch (error) {
17325
+ await modelClass.withdrawDocumentRegistration(connected.docId);
17326
+ const message = error instanceof Error ? error.message : String(error);
17327
+ const wrapped = new Error(
17328
+ `[initJsBao] registerModel('${modelName}') failed on document ${connected.docId}; registration is incomplete and can be retried: ` + message
17329
+ );
17330
+ wrapped.cause = error;
17331
+ throw wrapped;
17332
+ }
17333
+ this.markRuntimeInitialized(modelName, connected.docId);
17334
+ }
17335
+ if (added || didDocumentWork) {
17336
+ await this.modelRegistry.initializeRelationships(this.dbEngine);
17337
+ }
17338
+ }
17339
+ /**
17340
+ * Initialize one model for one already-connected document (#3615).
17341
+ *
17342
+ * The legacy half carries the window `initializeForDocument` leaves open: it
17343
+ * loads the Y.Map as records in an asynchronous transaction and installs its
17344
+ * observers afterwards, which on a document the app connected long ago is a
17345
+ * live document still receiving peer updates. A temporary deep observer
17346
+ * collects the record ids touched while that runs; once the instance's
17347
+ * indexing for the document has drained, each of them is written from its
17348
+ * CURRENT state, so the call resolves with the query tables holding every
17349
+ * update that arrived before it did.
17350
+ */
17351
+ async initializeModelForConnectedDocument(modelClass, modelName, connected) {
17352
+ const { docId, yDoc, permissionHint } = connected;
17353
+ const documentFormat = connected.documentFormat ?? 1;
17354
+ if (documentFormat === 2) {
17355
+ await modelClass.initializeForDocument(
17356
+ yDoc,
17357
+ this.dbEngine,
17358
+ docId,
17359
+ permissionHint,
17360
+ 2
17361
+ );
17362
+ await this.format2.hydrateModel(docId, modelName);
17363
+ return;
17364
+ }
17365
+ const touched = /* @__PURE__ */ new Set();
17366
+ const documentYMap = yDoc.getMap(modelName);
17367
+ const collect2 = (events) => {
17368
+ for (const event of events) {
17369
+ if (event.target === documentYMap) {
17370
+ for (const key of event.changes.keys.keys()) touched.add(key);
17371
+ } else if (event.path.length > 0) {
17372
+ touched.add(String(event.path[0]));
17373
+ }
17374
+ }
17375
+ };
17376
+ documentYMap.observeDeep(collect2);
17377
+ try {
17378
+ await modelClass.initializeForDocument(
17379
+ yDoc,
17380
+ this.dbEngine,
17381
+ docId,
17382
+ permissionHint,
17383
+ 1
17384
+ );
17385
+ await this.whenDocumentIndexed(docId);
17386
+ for (const recordId of touched) {
17387
+ await modelClass.reindexDocumentRecord(docId, recordId);
17388
+ }
17389
+ } finally {
17390
+ documentYMap.unobserveDeep(collect2);
17391
+ }
17392
+ }
15392
17393
  /** Resolves once every enqueued query-projection refresh has been applied. */
15393
17394
  whenFormat2ProjectionSettled() {
15394
17395
  return this.format2.whenProjectionSettled();
15395
17396
  }
17397
+ /**
17398
+ * Resolves once THIS instance's query tables have caught up with `docId`
17399
+ * (#3023). Scoped to the instance's own context, so a second client holding
17400
+ * the same document id never waits on this one's writes.
17401
+ */
17402
+ async whenDocumentIndexed(docId) {
17403
+ const drained = await this.ormContext.whenDocumentIndexed(docId);
17404
+ if (!drained) {
17405
+ Logger.warn(
17406
+ `[DocumentManager] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
17407
+ );
17408
+ }
17409
+ }
15396
17410
  getConnectedDocuments() {
15397
17411
  return new Map(this.connectedDocuments);
15398
17412
  }
@@ -15420,22 +17434,33 @@ var DocumentManagerImpl = class {
15420
17434
  };
15421
17435
  function bindSessionModels(ctx, registry) {
15422
17436
  for (const [modelName, modelClass] of registry.getActiveModels()) {
15423
- const current = modelClass?._ormContext;
15424
- if (current === ctx) continue;
15425
- if (!current || current.destroyed) {
15426
- Object.defineProperty(modelClass, "_ormContext", {
15427
- value: ctx,
15428
- writable: true,
15429
- configurable: true,
15430
- enumerable: false
15431
- });
15432
- ctx.boundModels.add(modelClass);
15433
- continue;
15434
- }
15435
- console.warn(
15436
- `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
17437
+ bindModelClass(ctx, modelName, modelClass, "warn");
17438
+ }
17439
+ }
17440
+ function bindModelClass(ctx, modelName, modelClass, onLiveConflict) {
17441
+ const current = modelClass?._ormContext;
17442
+ if (current === ctx) {
17443
+ ctx.boundModels.add(modelClass);
17444
+ return;
17445
+ }
17446
+ if (!current || current.destroyed) {
17447
+ Object.defineProperty(modelClass, "_ormContext", {
17448
+ value: ctx,
17449
+ writable: true,
17450
+ configurable: true,
17451
+ enumerable: false
17452
+ });
17453
+ ctx.boundModels.add(modelClass);
17454
+ return;
17455
+ }
17456
+ if (onLiveConflict === "throw") {
17457
+ throw new Error(
17458
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${current.instanceId}. Give instance #${ctx.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
15437
17459
  );
15438
17460
  }
17461
+ console.warn(
17462
+ `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
17463
+ );
15439
17464
  }
15440
17465
  async function initJsBaoCore(options, getEngine) {
15441
17466
  const { databaseConfig, models: modelClasses } = options;
@@ -15464,6 +17489,7 @@ async function initJsBaoCore(options, getEngine) {
15464
17489
  destroy: () => ormContext.destroy(),
15465
17490
  connectDocument: docManager.connectDocument.bind(docManager),
15466
17491
  disconnectDocument: docManager.disconnectDocument.bind(docManager),
17492
+ registerModel: docManager.registerModel.bind(docManager),
15467
17493
  format2SyncMarks: (docId) => format2SyncMarks(docId, ormContext),
15468
17494
  format2StoredEpoch: (docId) => format2StoredEpoch(dbEngine, docId, ormContext),
15469
17495
  // The pre-bind epoch read opens the same pool the bind will, so it needs
@@ -15475,17 +17501,42 @@ async function initJsBaoCore(options, getEngine) {
15475
17501
  ormContext,
15476
17502
  options.largeDocumentStoreScope?.() ?? void 0
15477
17503
  ),
17504
+ // #3754 — the three doors that remove a large document's local data.
17505
+ // The scope defaults to what an open would use and is otherwise the
17506
+ // caller's: at logout it names the user who is signing OUT, which this
17507
+ // engine no longer has any way to work out for itself.
17508
+ format2Purge: (docId, purgeOptions) => format2Purge(
17509
+ dbEngine,
17510
+ docId,
17511
+ {
17512
+ force: purgeOptions?.force,
17513
+ scope: purgeOptions?.scope ?? options.largeDocumentStoreScope?.() ?? void 0
17514
+ },
17515
+ ormContext
17516
+ ),
17517
+ format2PurgeAll: (purgeOptions) => format2PurgeAll(
17518
+ dbEngine,
17519
+ {
17520
+ ...purgeOptions?.keep ? { keep: purgeOptions.keep } : {},
17521
+ force: purgeOptions?.force,
17522
+ scope: purgeOptions?.scope ?? options.largeDocumentStoreScope?.() ?? void 0
17523
+ },
17524
+ ormContext
17525
+ ),
15478
17526
  format2Acknowledge: (docId, maxContiguousSeq) => format2Acknowledge(docId, maxContiguousSeq, ormContext),
15479
17527
  format2NoteEpoch: (docId, epoch) => format2NoteEpoch(docId, epoch, ormContext),
15480
17528
  format2NoteSync: (docId, at, windowDays) => format2NoteSync(docId, at, windowDays, ormContext),
15481
17529
  format2NoteWindow: (docId, windowDays) => format2NoteWindow(docId, windowDays, ormContext),
17530
+ format2OnWriteRefused: (listener) => ormContext.onFormat2WriteRefused(listener),
15482
17531
  format2NoteClockOffset: (docId, offsetMs) => format2NoteClockOffset(docId, offsetMs, ormContext),
15483
17532
  format2ForgetPendingOps: (docId, seqs) => format2ForgetPendingOps(docId, seqs, ormContext),
15484
17533
  format2NoteDiscontinuity: (docId, epoch) => format2NoteDiscontinuity(docId, epoch, ormContext),
15485
17534
  format2ClearDiscontinuities: (docId, through) => format2ClearDiscontinuities(docId, through, ormContext),
17535
+ format2NoteDeferredReplay: (docId, note) => format2NoteDeferredReplay(docId, note, ormContext),
17536
+ format2ClearDeferredReplay: (docId) => format2ClearDeferredReplay(docId, ormContext),
15486
17537
  format2NarrowPendingOps: (docId, ops) => format2NarrowPendingOps(docId, ops, ormContext),
15487
17538
  format2PendingOps: (docId) => format2PendingOps(docId, ormContext),
15488
- format2SetEpoch: (docId, epoch) => format2SetEpoch(docId, epoch, ormContext),
17539
+ format2SetEpoch: (docId, epoch, deferredReplay) => format2SetEpoch(docId, epoch, ormContext, deferredReplay),
15489
17540
  format2HoldWrites: (docId, run2) => format2HoldWrites(docId, run2, ormContext),
15490
17541
  format2RebindEpochDocument: docManager.rebindEpochDocument.bind(docManager),
15491
17542
  format2ApplySealedOverlay: docManager.applySealedOverlay.bind(docManager),
@@ -15496,6 +17547,7 @@ async function initJsBaoCore(options, getEngine) {
15496
17547
  // queue updates after it). The transport awaits it before an update
15497
17548
  // from the room counts as applied.
15498
17549
  whenFormat2ProjectionSettled: docManager.whenFormat2ProjectionSettled.bind(docManager),
17550
+ whenDocumentIndexed: docManager.whenDocumentIndexed.bind(docManager),
15499
17551
  format2LoadSnapshot: docManager.loadSnapshot.bind(docManager),
15500
17552
  format2LocalModels: (docId) => docManager.localModels(docId),
15501
17553
  format2RecordPresence: (docId, records) => docManager.recordPresence(docId, records),
@@ -15899,6 +17951,26 @@ function schemaToToml(schema) {
15899
17951
 
15900
17952
  // src/models/tomlLoader.ts
15901
17953
  var import_smol_toml = require("smol-toml");
17954
+
17955
+ // src/utils/reservedFields.ts
17956
+ function checkReservedFieldName(fieldName) {
17957
+ if (fieldName === "id") return null;
17958
+ if (fieldName === "type") {
17959
+ return `Field 'type' is reserved (maps to internal _type column in queries)`;
17960
+ }
17961
+ if (fieldName.startsWith("_")) {
17962
+ return `Field '${fieldName}' is reserved (fields starting with '_' are internal)`;
17963
+ }
17964
+ return null;
17965
+ }
17966
+ var ReservedFieldNameError = class extends Error {
17967
+ constructor(message) {
17968
+ super(message);
17969
+ this.name = "ReservedFieldNameError";
17970
+ }
17971
+ };
17972
+
17973
+ // src/models/tomlLoader.ts
15902
17974
  var VALID_FIELD_TYPES = /* @__PURE__ */ new Set([
15903
17975
  "string",
15904
17976
  "number",
@@ -16045,6 +18117,7 @@ function parseRelationship(raw, context, strict) {
16045
18117
  }
16046
18118
  function loadSchemaFromTomlString(tomlString, options = {}) {
16047
18119
  const strict = options.strict !== false;
18120
+ const rejectReserved = options.rejectReservedFieldNames === true;
16048
18121
  const parsed = (0, import_smol_toml.parse)(tomlString);
16049
18122
  const models = parsed.models;
16050
18123
  if (!models || typeof models !== "object") {
@@ -16061,11 +18134,14 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
16061
18134
  const fields = {};
16062
18135
  if (modelDef.fields) {
16063
18136
  for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
16064
- fields[fieldName] = parseFieldOptions(
16065
- fieldDef,
16066
- `[models.${modelName}.fields.${fieldName}]`,
16067
- strict
16068
- );
18137
+ const context = `[models.${modelName}.fields.${fieldName}]`;
18138
+ if (rejectReserved) {
18139
+ const reserved = checkReservedFieldName(fieldName);
18140
+ if (reserved) {
18141
+ throw new ReservedFieldNameError(`${context}: ${reserved}`);
18142
+ }
18143
+ }
18144
+ fields[fieldName] = parseFieldOptions(fieldDef, context, strict);
16069
18145
  }
16070
18146
  }
16071
18147
  let relationships;
@@ -16112,6 +18188,18 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
16112
18188
  }
16113
18189
  return schemas;
16114
18190
  }
18191
+ function findReservedFieldNameInToml(tomlString) {
18192
+ try {
18193
+ loadSchemaFromTomlString(tomlString, {
18194
+ strict: false,
18195
+ rejectReservedFieldNames: true
18196
+ });
18197
+ return null;
18198
+ } catch (error) {
18199
+ if (error instanceof ReservedFieldNameError) return error.message;
18200
+ return null;
18201
+ }
18202
+ }
16115
18203
 
16116
18204
  // src/browser.ts
16117
18205
  init_metaSync();
@@ -16125,8 +18213,11 @@ init_format2Observer();
16125
18213
 
16126
18214
  // src/models/format2EpochHandoff.ts
16127
18215
  init_largeDocuments();
18216
+ function format2EpochDocPrefix(docId) {
18217
+ return `${docId}@`;
18218
+ }
16128
18219
  function format2EpochDocName(docId, epoch) {
16129
- return `${docId}@${epoch}`;
18220
+ return `${format2EpochDocPrefix(docId)}${epoch}`;
16130
18221
  }
16131
18222
  function carryUnackedOverlay(ops, readOverlay, options = {}) {
16132
18223
  const suppressed = new Set(
@@ -16274,6 +18365,19 @@ async function followEpoch(host, nextEpoch, options = {}) {
16274
18365
  const owedOps = host.pendingOps();
16275
18366
  const readSealed = (model, recordId) => readOverlayRecord(sealed.getMap(model), recordId);
16276
18367
  const owedOverlay = defer ? snapshotOwedOverlay(owedOps, readSealed) : null;
18368
+ const owedNote = defer && owedOps.length > 0 && host.noteDeferredReplay ? {
18369
+ throughSeq: Math.max(...owedOps.map((op) => op.seq)),
18370
+ fromEpoch: current,
18371
+ toEpoch: nextEpoch,
18372
+ kind: "ordinary"
18373
+ } : null;
18374
+ if (owedNote && host.noteDeferredReplay) {
18375
+ try {
18376
+ await host.noteDeferredReplay(owedNote);
18377
+ } catch {
18378
+ return { moved: false, reason: "note-refused" };
18379
+ }
18380
+ }
16277
18381
  let carried = [];
16278
18382
  if (!defer) {
16279
18383
  const owed = resolveReplay ? resolveReplay(owedOps) : { ops: owedOps, suppressDeletes: [] };
@@ -16294,7 +18398,10 @@ async function followEpoch(host, nextEpoch, options = {}) {
16294
18398
  await host.install(
16295
18399
  fresh,
16296
18400
  nextEpoch,
16297
- defer ? { restorePending: false } : void 0
18401
+ defer ? {
18402
+ restorePending: false,
18403
+ ...owedNote ? { deferredReplay: owedNote } : {}
18404
+ } : void 0
16298
18405
  );
16299
18406
  await host.resync();
16300
18407
  if (!defer) {