js-bao 0.7.0-alpha.7 → 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
  *
@@ -5523,6 +6122,35 @@ var init_OrmContext = __esm({
5523
6122
  this.modelDocMappingChangedListeners.add(listener);
5524
6123
  return () => this.modelDocMappingChangedListeners.delete(listener);
5525
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
+ }
5526
6154
  /** Drop the model→document defaults that point at a disconnected document. */
5527
6155
  clearMappingsForDocId(docId) {
5528
6156
  const toRemove = [];
@@ -5588,6 +6216,7 @@ var init_OrmContext = __esm({
5588
6216
  this.globalDefaultDocId = void 0;
5589
6217
  this.defaultDocChangedListeners.clear();
5590
6218
  this.modelDocMappingChangedListeners.clear();
6219
+ this.format2WriteRefusedListeners.clear();
5591
6220
  this.format2Documents.clear();
5592
6221
  this.format2Transitions.clear();
5593
6222
  this.documentIndexingWork.clear();
@@ -7125,6 +7754,41 @@ var init_BaseModel = __esm({
7125
7754
  resolveOrmContext(this).getFormat2StoreAsync(docId)?.assertReadable();
7126
7755
  }
7127
7756
  }
7757
+ /**
7758
+ * The one gate every local write on a large document passes (#3758).
7759
+ *
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.
7769
+ */
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
+ }
7128
7792
  /**
7129
7793
  * Build an instance already holding its merged-view row (#3429).
7130
7794
  *
@@ -8923,8 +9587,12 @@ var init_BaseModel = __esm({
8923
9587
  }
8924
9588
  const format2Store = modelConstructor.getFormat2StoreAsync(targetDocId);
8925
9589
  if (format2Store) {
8926
- format2Store.assertReadable();
8927
- assertWithinOfflineWindow(targetDocId, format2Store);
9590
+ this.constructor.assertFormat2Writable(
9591
+ targetDocId,
9592
+ modelName,
9593
+ this.id,
9594
+ format2Store
9595
+ );
8928
9596
  const target = await this.constructor.resolveFormat2Target(
8929
9597
  targetDocId,
8930
9598
  modelName,
@@ -9698,8 +10366,12 @@ var init_BaseModel = __esm({
9698
10366
  sourceYMap = modelConstructor.documentYMaps.get(`${docId}_${modelName}`) || null;
9699
10367
  const format2Store = modelConstructor.getFormat2StoreAsync(docId);
9700
10368
  if (format2Store) {
9701
- format2Store.assertReadable();
9702
- assertWithinOfflineWindow(docId, format2Store);
10369
+ modelConstructor.assertFormat2Writable(
10370
+ docId,
10371
+ modelName,
10372
+ this.id,
10373
+ format2Store
10374
+ );
9703
10375
  const target = await modelConstructor.resolveFormat2Target(
9704
10376
  docId,
9705
10377
  modelName,
@@ -9724,10 +10396,18 @@ var init_BaseModel = __esm({
9724
10396
  }
9725
10397
  const recordYMap = sourceYMap.get(this.id);
9726
10398
  if (!recordYMap) {
9727
- Logger.warn(
9728
- `[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}. Cannot remove from unique constraint maps. Attempting main map deletion only.`
9729
- );
9730
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
+ }
9731
10411
  sourceYMap.delete(this.id);
9732
10412
  }, `delete-ghost-record-${modelName}-${this.id}`);
9733
10413
  return;
@@ -9747,6 +10427,7 @@ var init_BaseModel = __esm({
9747
10427
  if (uniqueKey !== null) {
9748
10428
  const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
9749
10429
  const constraintMap = sourceYDoc.getMap(constraintMapName);
10430
+ if (constraintMap.get(uniqueKey) !== this.id) continue;
9750
10431
  Logger.verbose(
9751
10432
  `[${modelName}] Delete: Removing unique key '${uniqueKey.substring(
9752
10433
  0,
@@ -10016,13 +10697,17 @@ var init_BaseModel = __esm({
10016
10697
  }
10017
10698
  const isFirstPage = !options?.uniqueStartKey;
10018
10699
  const direction = options?.direction || 1;
10700
+ const aliasedSortFields = translatedQuery.cursorSortValueFields ?? [];
10019
10701
  const cursors = CursorManager.generateResultCursors(
10020
- results,
10702
+ results.map((row) => cursorMintingRecord(row, aliasedSortFields)),
10021
10703
  translatedQuery.sortFields,
10022
10704
  direction,
10023
10705
  hasMore,
10024
10706
  isFirstPage
10025
10707
  );
10708
+ results = results.map(
10709
+ (row) => stripCursorSortValues(row, aliasedSortFields)
10710
+ );
10026
10711
  if (direction === -1) {
10027
10712
  results.reverse();
10028
10713
  }
@@ -10794,24 +11479,25 @@ var init_BaseModel = __esm({
10794
11479
  constraint.name
10795
11480
  );
10796
11481
  const constraintMap2 = yDoc.getMap(constraintMapName2);
10797
- 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
+ );
10798
11491
  if (recordId2) {
10799
- const documentYMapKey = `${docId}_${currentModelName}`;
10800
- const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
10801
- if (documentYMap) {
10802
- const recordYMap2 = documentYMap.get(recordId2);
10803
- if (recordYMap2 && recordYMap2 instanceof Y7.Map) {
10804
- const instance = new modelConstructor({
10805
- id: recordId2
10806
- });
10807
- instance._metaDocId = docId;
10808
- instance._metaPermissionHint = documentInfo.permissionHint;
10809
- Logger.verbose(
10810
- `[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
10811
- );
10812
- return instance;
10813
- }
10814
- }
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;
10815
11501
  }
10816
11502
  }
10817
11503
  const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
@@ -10842,7 +11528,13 @@ var init_BaseModel = __esm({
10842
11528
  constraint.name
10843
11529
  );
10844
11530
  const constraintMap = legacyDocInfo.yDoc.getMap(constraintMapName);
10845
- const recordId = constraintMap.get(uniqueKeyString);
11531
+ const recordId = modelConstructor._readableUniqueOwner(
11532
+ legacyDocumentMap,
11533
+ constraintMap,
11534
+ constraint,
11535
+ uniqueKeyString,
11536
+ currentModelName
11537
+ );
10846
11538
  if (!recordId) {
10847
11539
  const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
10848
11540
  constraint,
@@ -10928,7 +11620,13 @@ var init_BaseModel = __esm({
10928
11620
  const docInfo = connectedDocuments.get(docId);
10929
11621
  if (docInfo) {
10930
11622
  const constraintMap = docInfo.yDoc.getMap(constraintMapName);
10931
- 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
+ );
10932
11630
  if (recordId) {
10933
11631
  existingRecord = await modelConstructor.find(
10934
11632
  recordId
@@ -11394,6 +12092,7 @@ __export(browser_exports, {
11394
12092
  completeOverlayEntries: () => completeOverlayEntries,
11395
12093
  configuredOfflineWindowDays: () => configuredOfflineWindowDays,
11396
12094
  createModelClass: () => createModelClass,
12095
+ decideDeferredReplay: () => decideDeferredReplay,
11397
12096
  decodeFormat2StoreError: () => decodeFormat2StoreError,
11398
12097
  defineModelSchema: () => defineModelSchema,
11399
12098
  detectEnvironment: () => detectEnvironment,
@@ -11409,6 +12108,7 @@ __export(browser_exports, {
11409
12108
  followEpoch: () => followEpoch,
11410
12109
  format2ClientDDL: () => format2ClientDDL,
11411
12110
  format2EpochDocName: () => format2EpochDocName,
12111
+ format2EpochDocPrefix: () => format2EpochDocPrefix,
11412
12112
  format2StringSetMembers: () => format2StringSetMembers,
11413
12113
  format2TableNames: () => format2TableNames,
11414
12114
  generateULID: () => generateULID,
@@ -11548,11 +12248,35 @@ var DatabaseEngine = class {
11548
12248
  */
11549
12249
  format2StoredEpoch;
11550
12250
  /**
11551
- * The engine that holds `docIds`' derived query tables for `modelName`.
12251
+ * Remove one large document's local data, and the store holding it when
12252
+ * nothing else is in there.
11552
12253
  *
11553
- * An engine whose large documents' query tables live apart from the
11554
- * ordinary documents' — the browser's worker-hosted engine — answers with
11555
- * the engine to run the statement on, or refuses a scope that would need
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;
12274
+ /**
12275
+ * The engine that holds `docIds`' derived query tables for `modelName`.
12276
+ *
12277
+ * An engine whose large documents' query tables live apart from the
12278
+ * ordinary documents' — the browser's worker-hosted engine — answers with
12279
+ * the engine to run the statement on, or refuses a scope that would need
11556
12280
  * both. Engines with one database for everything leave it undefined and
11557
12281
  * `BaseModel` uses the engine itself.
11558
12282
  */
@@ -12720,6 +13444,8 @@ var Format2NamespaceAttachment = class {
12720
13444
  */
12721
13445
  hiding = false;
12722
13446
  leaderRelease = null;
13447
+ /** Set by a `retire` that succeeded: the pool's VFS is paused (#3754). */
13448
+ hasRetired = false;
12723
13449
  election = null;
12724
13450
  electionQueued = false;
12725
13451
  broker = null;
@@ -12783,6 +13509,39 @@ var Format2NamespaceAttachment = class {
12783
13509
  holders(docId) {
12784
13510
  return this.send(FORMAT2_WORKER_CHANNEL, { kind: "holders", docId });
12785
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
+ }
12786
13545
  release(docId, clientId, wipeIfLast) {
12787
13546
  return this.send(FORMAT2_WORKER_CHANNEL, {
12788
13547
  kind: "release",
@@ -13307,7 +14066,7 @@ var Format2NamespaceAttachment = class {
13307
14066
  * lifecycle step-down above never consults it, because a frozen page would
13308
14067
  * never reach the end of it.
13309
14068
  */
13310
- async destroy() {
14069
+ async destroy(options = {}) {
13311
14070
  if (this.destroyed) return;
13312
14071
  const current = this.attachment;
13313
14072
  const leaving = Array.from(this.sessions);
@@ -13324,7 +14083,9 @@ var Format2NamespaceAttachment = class {
13324
14083
  this.attachment = null;
13325
14084
  for (const session of leaving) session.detach();
13326
14085
  if (current?.role === "leader") this.sayBye();
13327
- if (current) {
14086
+ if (current && options.alreadyRetired) {
14087
+ current.handle.terminate();
14088
+ } else if (current) {
13328
14089
  try {
13329
14090
  await current.handle.close(this.options.closeBoundMs);
13330
14091
  } catch (error) {
@@ -13527,6 +14288,42 @@ function observeFormat2Lifecycle(target, handlers) {
13527
14288
  function format2ClientLockName(namespace, clientId) {
13528
14289
  return `${namespace.lockName}:client:${encodePart(clientId)}`;
13529
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
+ }
13530
14327
  var PREFIX = "js-bao-format2-";
13531
14328
  function encodePart(value) {
13532
14329
  let out = "";
@@ -13593,6 +14390,8 @@ var OpfsEngine = class {
13593
14390
  destroyed = false;
13594
14391
  /** Fires on `destroy()`: no open in progress spawns another worker. */
13595
14392
  teardown = new AbortController();
14393
+ /** The OPFS root, once something has asked for it (#3754). */
14394
+ resolvedRoot = null;
13596
14395
  constructor(options = {}, internals = {}) {
13597
14396
  this.internals = { ...options.internals ?? {}, ...internals };
13598
14397
  this.mirror = new SqljsEngine({
@@ -13692,10 +14491,18 @@ var OpfsEngine = class {
13692
14491
  */
13693
14492
  async createFormat2StoreHost(docId, options = {}) {
13694
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
+ };
13695
14501
  let entry;
13696
14502
  try {
13697
14503
  entry = await this.ensureWorker(docId, options.scope);
13698
14504
  } catch (error) {
14505
+ releaseDocument();
13699
14506
  const reason = error?.message ?? String(error);
13700
14507
  Logger.error(
13701
14508
  `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
@@ -13708,6 +14515,7 @@ var OpfsEngine = class {
13708
14515
  host = entry.attachment.serveStore(docId, clientId);
13709
14516
  await host.dispatch({ kind: "storedEpoch" });
13710
14517
  } catch (error) {
14518
+ releaseDocument();
13711
14519
  const reason = error?.message ?? String(error);
13712
14520
  Logger.error(
13713
14521
  `[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
@@ -13720,9 +14528,10 @@ var OpfsEngine = class {
13720
14528
  else this.docs.set(docId, { entry, sessions: 1 });
13721
14529
  const held = clientId ? this.holdPresence(format2ClientLockName(namespace, clientId)) : null;
13722
14530
  if (held) this.presence.add(held);
13723
- const releasePresence = held ? () => {
13724
- if (this.presence.delete(held)) held();
13725
- } : null;
14531
+ const releasePresence = () => {
14532
+ if (held && this.presence.delete(held)) held();
14533
+ releaseDocument();
14534
+ };
13726
14535
  const engine = this;
13727
14536
  const closeOnce = async (closeOptions) => {
13728
14537
  const routing2 = engine.docs.get(docId);
@@ -13730,7 +14539,7 @@ var OpfsEngine = class {
13730
14539
  entry.docIds.delete(docId);
13731
14540
  engine.docs.delete(docId);
13732
14541
  }
13733
- releasePresence?.();
14542
+ releasePresence();
13734
14543
  return host.close(closeOptions);
13735
14544
  };
13736
14545
  let closing = null;
@@ -13766,6 +14575,368 @@ var OpfsEngine = class {
13766
14575
  return null;
13767
14576
  }
13768
14577
  }
14578
+ // ── Removing a document's store, and the pool holding it (#3754) ──
14579
+ /**
14580
+ * Remove `docId`'s local data, and its pool's directory once it holds
14581
+ * nothing else.
14582
+ *
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).
14588
+ *
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).
14594
+ */
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
14616
+ )
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).`
14621
+ );
14622
+ return { purged: false, reason: "held", presence: true };
14623
+ }
14624
+ return taken.value;
14625
+ }
14626
+ /**
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
+ }
13769
14940
  /** `navigator.locks`, unless a test handed one in. `null` means none. */
13770
14941
  locks() {
13771
14942
  if (this.internals.locks !== void 0) return this.internals.locks;
@@ -13786,7 +14957,7 @@ var OpfsEngine = class {
13786
14957
  * queued is withdrawn. A presence lock left held says a client is still here
13787
14958
  * when its writes are sitting in the log waiting to be adopted.
13788
14959
  */
13789
- holdPresence(name) {
14960
+ holdPresence(name, mode = "exclusive") {
13790
14961
  const locks = this.locks();
13791
14962
  if (!locks) return null;
13792
14963
  let release = null;
@@ -13795,7 +14966,7 @@ var OpfsEngine = class {
13795
14966
  void Promise.resolve(
13796
14967
  locks.request(
13797
14968
  name,
13798
- { mode: "exclusive", ...withdraw ? { signal: withdraw.signal } : {} },
14969
+ { mode, ...withdraw ? { signal: withdraw.signal } : {} },
13799
14970
  () => new Promise((resolve) => {
13800
14971
  if (done) resolve();
13801
14972
  else release = resolve;
@@ -13809,6 +14980,35 @@ var OpfsEngine = class {
13809
14980
  withdraw?.abort();
13810
14981
  };
13811
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
+ }
13812
15012
  /** Whether somebody holds `name` right now. Never waits. */
13813
15013
  async probeLock(name) {
13814
15014
  const locks = this.locks();
@@ -13980,6 +15180,14 @@ var OpfsEngine = class {
13980
15180
  });
13981
15181
  }
13982
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
+ }
13983
15191
  function withinBound2(promise, boundMs) {
13984
15192
  let timer;
13985
15193
  const bound = new Promise((resolve) => {
@@ -14408,6 +15616,22 @@ function sameOverlayValue(a, b) {
14408
15616
  if (a === null || a === void 0) return b === null || b === void 0;
14409
15617
  return JSON.stringify(a) === JSON.stringify(b);
14410
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
+ }
14411
15635
  function restorePendingOverlay(doc, store, verdicts, options = {}) {
14412
15636
  const result = {
14413
15637
  restored: [],
@@ -14415,9 +15639,10 @@ function restorePendingOverlay(doc, store, verdicts, options = {}) {
14415
15639
  deferred: []
14416
15640
  };
14417
15641
  const defer = options.deferAtOrBelowEpoch ?? 0;
15642
+ const deferThrough = options.deferThroughSeq ?? 0;
14418
15643
  const missing = [];
14419
15644
  for (const op of store.pendingOps()) {
14420
- if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer) {
15645
+ if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer || deferThrough > 0 && op.seq <= deferThrough) {
14421
15646
  result.deferred.push(op.seq);
14422
15647
  continue;
14423
15648
  }
@@ -14451,17 +15676,71 @@ function statePendingOps(doc, ops, options = {}) {
14451
15676
  (entry) => `${entry.model} ${entry.recordId}`
14452
15677
  )
14453
15678
  );
14454
- const stated = [...ops].sort((a, b) => a.seq - b.seq).filter(
14455
- (op) => op.mutation !== null && !(op.op === "delete" && suppressed.has(`${op.model} ${op.recordId}`))
14456
- );
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
+ }
14457
15718
  if (stated.length === 0) return 0;
14458
15719
  doc.transact(() => {
14459
- for (const op of stated) {
14460
- applyOverlayMutation(doc.getMap(op.model), op.mutation);
15720
+ for (const { op, mutation, keep } of stated) {
15721
+ applyOverlayMutation(doc.getMap(op.model), mutation, { keep });
14461
15722
  }
14462
15723
  });
14463
15724
  return stated.length;
14464
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
+ }
14465
15744
 
14466
15745
  // src/models/format2SnapshotLoader.ts
14467
15746
  init_snapshotChunks();
@@ -14775,17 +16054,21 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14775
16054
  const crossed = store.discontinuityEpochs();
14776
16055
  const boundary = crossed.length > 0 ? crossed[crossed.length - 1] : 0;
14777
16056
  const deferAtOrBelowEpoch = boundary > 0 && store.epoch() > boundary ? boundary : 0;
14778
- const restored = restorePendingOverlay(
14779
- yDoc,
14780
- store,
14781
- verdicts,
14782
- deferAtOrBelowEpoch > 0 ? { deferAtOrBelowEpoch } : {}
14783
- );
14784
- 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) {
14785
16063
  Logger.warn(
14786
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}.`
14787
16065
  );
14788
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
+ }
14789
16072
  const adoptedWasRestored = verdicts !== void 0 && restored.restored.some((seq) => verdicts.has(seq));
14790
16073
  if (adoptedWasRestored) {
14791
16074
  foldFormat2Overlay(
@@ -14809,6 +16092,47 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14809
16092
  store.onReattached(() => this.revalidateQueryTables(docId, store))
14810
16093
  );
14811
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
+ }
14812
16136
  /**
14813
16137
  * Rebuild whatever the query tables lost while this tab was off the worker
14814
16138
  * (#3431, finding 3431-R12).
@@ -15452,6 +16776,8 @@ function withinBound3(promise, boundMs) {
15452
16776
 
15453
16777
  // src/models/format2Sync.ts
15454
16778
  init_OrmContext();
16779
+ init_format2RecordStore();
16780
+ init_logger();
15455
16781
  function format2StoredEpoch(engine, docId, ctx) {
15456
16782
  if (!docId) return null;
15457
16783
  const bound = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
@@ -15488,6 +16814,82 @@ async function format2StoredEpochAsync(engine, docId, ctx, scope) {
15488
16814
  }
15489
16815
  return format2StoredEpoch(engine, docId, ctx);
15490
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
+ }
15491
16893
  function format2SyncMarks(docId, ctx) {
15492
16894
  const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15493
16895
  if (!store) return null;
@@ -15497,7 +16899,8 @@ function format2SyncMarks(docId, ctx) {
15497
16899
  epoch: store.epoch(),
15498
16900
  clockOffsetKnown: store.clockOffsetKnown(),
15499
16901
  discontinuityEpochs: store.discontinuityEpochs(),
15500
- baseComplete: store.baseComplete()
16902
+ baseComplete: store.baseComplete(),
16903
+ deferredReplay: store.deferredReplay()
15501
16904
  };
15502
16905
  }
15503
16906
  function format2NoteEpoch(docId, epoch, ctx) {
@@ -15509,11 +16912,21 @@ function format2NoteEpoch(docId, epoch, ctx) {
15509
16912
  function format2PendingOps(docId, ctx) {
15510
16913
  return (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId)?.pendingOps() ?? [];
15511
16914
  }
15512
- function format2SetEpoch(docId, epoch, ctx) {
16915
+ function format2SetEpoch(docId, epoch, ctx, deferredReplay) {
15513
16916
  const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15514
16917
  if (!store) return;
15515
16918
  if (!Number.isInteger(epoch) || epoch <= 0) return;
15516
- 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();
15517
16930
  }
15518
16931
  function format2HoldWrites(docId, run2, ctx) {
15519
16932
  return (ctx ?? getDefaultOrmContext()).holdFormat2Writes(docId, run2);
@@ -16088,17 +17501,42 @@ async function initJsBaoCore(options, getEngine) {
16088
17501
  ormContext,
16089
17502
  options.largeDocumentStoreScope?.() ?? void 0
16090
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
+ ),
16091
17526
  format2Acknowledge: (docId, maxContiguousSeq) => format2Acknowledge(docId, maxContiguousSeq, ormContext),
16092
17527
  format2NoteEpoch: (docId, epoch) => format2NoteEpoch(docId, epoch, ormContext),
16093
17528
  format2NoteSync: (docId, at, windowDays) => format2NoteSync(docId, at, windowDays, ormContext),
16094
17529
  format2NoteWindow: (docId, windowDays) => format2NoteWindow(docId, windowDays, ormContext),
17530
+ format2OnWriteRefused: (listener) => ormContext.onFormat2WriteRefused(listener),
16095
17531
  format2NoteClockOffset: (docId, offsetMs) => format2NoteClockOffset(docId, offsetMs, ormContext),
16096
17532
  format2ForgetPendingOps: (docId, seqs) => format2ForgetPendingOps(docId, seqs, ormContext),
16097
17533
  format2NoteDiscontinuity: (docId, epoch) => format2NoteDiscontinuity(docId, epoch, ormContext),
16098
17534
  format2ClearDiscontinuities: (docId, through) => format2ClearDiscontinuities(docId, through, ormContext),
17535
+ format2NoteDeferredReplay: (docId, note) => format2NoteDeferredReplay(docId, note, ormContext),
17536
+ format2ClearDeferredReplay: (docId) => format2ClearDeferredReplay(docId, ormContext),
16099
17537
  format2NarrowPendingOps: (docId, ops) => format2NarrowPendingOps(docId, ops, ormContext),
16100
17538
  format2PendingOps: (docId) => format2PendingOps(docId, ormContext),
16101
- format2SetEpoch: (docId, epoch) => format2SetEpoch(docId, epoch, ormContext),
17539
+ format2SetEpoch: (docId, epoch, deferredReplay) => format2SetEpoch(docId, epoch, ormContext, deferredReplay),
16102
17540
  format2HoldWrites: (docId, run2) => format2HoldWrites(docId, run2, ormContext),
16103
17541
  format2RebindEpochDocument: docManager.rebindEpochDocument.bind(docManager),
16104
17542
  format2ApplySealedOverlay: docManager.applySealedOverlay.bind(docManager),
@@ -16775,8 +18213,11 @@ init_format2Observer();
16775
18213
 
16776
18214
  // src/models/format2EpochHandoff.ts
16777
18215
  init_largeDocuments();
18216
+ function format2EpochDocPrefix(docId) {
18217
+ return `${docId}@`;
18218
+ }
16778
18219
  function format2EpochDocName(docId, epoch) {
16779
- return `${docId}@${epoch}`;
18220
+ return `${format2EpochDocPrefix(docId)}${epoch}`;
16780
18221
  }
16781
18222
  function carryUnackedOverlay(ops, readOverlay, options = {}) {
16782
18223
  const suppressed = new Set(
@@ -16924,6 +18365,19 @@ async function followEpoch(host, nextEpoch, options = {}) {
16924
18365
  const owedOps = host.pendingOps();
16925
18366
  const readSealed = (model, recordId) => readOverlayRecord(sealed.getMap(model), recordId);
16926
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
+ }
16927
18381
  let carried = [];
16928
18382
  if (!defer) {
16929
18383
  const owed = resolveReplay ? resolveReplay(owedOps) : { ops: owedOps, suppressDeletes: [] };
@@ -16944,7 +18398,10 @@ async function followEpoch(host, nextEpoch, options = {}) {
16944
18398
  await host.install(
16945
18399
  fresh,
16946
18400
  nextEpoch,
16947
- defer ? { restorePending: false } : void 0
18401
+ defer ? {
18402
+ restorePending: false,
18403
+ ...owedNote ? { deferredReplay: owedNote } : {}
18404
+ } : void 0
16948
18405
  );
16949
18406
  await host.resync();
16950
18407
  if (!defer) {