js-bao 0.7.0-alpha.5 → 0.7.0-alpha.7

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
@@ -1259,11 +1259,71 @@ var init_yjsValues = __esm({
1259
1259
  }
1260
1260
  });
1261
1261
 
1262
+ // src/utils/uniqueIndex.ts
1263
+ function buildUniqueKey(recordData, fields) {
1264
+ const keyParts = [];
1265
+ for (const field of fields) {
1266
+ const value = recordData instanceof Y2.Map ? recordData.get(field) : recordData[field];
1267
+ if (value === null || value === void 0) {
1268
+ return null;
1269
+ }
1270
+ keyParts.push(String(value));
1271
+ }
1272
+ return keyParts.join("|");
1273
+ }
1274
+ function uniqueIndexMapName(modelName, constraintName) {
1275
+ return `_uniqueIdx_${modelName}_${constraintName}`;
1276
+ }
1277
+ function fieldUniqueConstraintName(modelName, fieldName) {
1278
+ return `${modelName}_${fieldName}_unique`;
1279
+ }
1280
+ function resolveUniqueConstraints(modelName, fields, customConstraints) {
1281
+ const resolved = [];
1282
+ for (const [fieldName, options] of fields.entries()) {
1283
+ if (options.unique) {
1284
+ resolved.push({
1285
+ name: fieldUniqueConstraintName(modelName, fieldName),
1286
+ fields: [fieldName]
1287
+ });
1288
+ }
1289
+ }
1290
+ if (customConstraints) {
1291
+ for (const constraint of customConstraints) {
1292
+ const missingField = constraint.fields.find(
1293
+ (field) => !fields.has(field)
1294
+ );
1295
+ if (missingField) {
1296
+ console.warn(
1297
+ `[defineModelSchema] Unique constraint "${constraint.name}" for model "${modelName}" references unknown field "${missingField}". Skipping.`
1298
+ );
1299
+ continue;
1300
+ }
1301
+ if (resolved.some((item) => item.name === constraint.name)) {
1302
+ console.warn(
1303
+ `[defineModelSchema] Duplicate unique constraint name "${constraint.name}" in model "${modelName}".`
1304
+ );
1305
+ }
1306
+ resolved.push({
1307
+ name: constraint.name,
1308
+ fields: [...constraint.fields]
1309
+ });
1310
+ }
1311
+ }
1312
+ return resolved;
1313
+ }
1314
+ var Y2;
1315
+ var init_uniqueIndex = __esm({
1316
+ "src/utils/uniqueIndex.ts"() {
1317
+ "use strict";
1318
+ Y2 = __toESM(require("yjs"), 1);
1319
+ }
1320
+ });
1321
+
1262
1322
  // src/models/writeLifecycle.ts
1263
1323
  function ensureStringSetYMap(rm, fieldName) {
1264
1324
  const nested = rm.get(fieldName);
1265
- if (nested instanceof Y2.Map) return nested;
1266
- const fresh = new Y2.Map();
1325
+ if (nested instanceof Y3.Map) return nested;
1326
+ const fresh = new Y3.Map();
1267
1327
  if (nested && typeof nested === "object") {
1268
1328
  for (const [member, marker] of Object.entries(
1269
1329
  nested
@@ -1301,11 +1361,11 @@ function registerFunctionDefaultGenerator(name, fn) {
1301
1361
  function resolveLiteralOrFunctionDefault(rawDefault) {
1302
1362
  return typeof rawDefault === "function" ? rawDefault() : rawDefault;
1303
1363
  }
1304
- var Y2, import_ulid, KNOWN_FIELD_TYPE_MAP, KNOWN_FIELD_TYPES, FUNCTION_DEFAULT_GENERATORS, UNKNOWN_FUNCTION_DEFAULT;
1364
+ var Y3, import_ulid, KNOWN_FIELD_TYPE_MAP, KNOWN_FIELD_TYPES, FUNCTION_DEFAULT_GENERATORS, UNKNOWN_FUNCTION_DEFAULT;
1305
1365
  var init_writeLifecycle = __esm({
1306
1366
  "src/models/writeLifecycle.ts"() {
1307
1367
  "use strict";
1308
- Y2 = __toESM(require("yjs"), 1);
1368
+ Y3 = __toESM(require("yjs"), 1);
1309
1369
  import_ulid = require("ulid");
1310
1370
  KNOWN_FIELD_TYPE_MAP = {
1311
1371
  string: true,
@@ -1362,6 +1422,10 @@ function syncModelMeta(yDoc, modelName, schema) {
1362
1422
  synced = /* @__PURE__ */ new Set();
1363
1423
  _syncedCache.set(yDoc, synced);
1364
1424
  }
1425
+ writeModelMeta(yDoc, modelName, schema);
1426
+ synced.add(modelName);
1427
+ }
1428
+ function writeModelMeta(yDoc, modelName, schema) {
1365
1429
  const meta = yDoc.getMap(`_meta_${modelName}`);
1366
1430
  for (const [fieldName, fieldOpts] of schema.fields.entries()) {
1367
1431
  syncFieldMeta(meta, fieldName, fieldOpts);
@@ -1372,7 +1436,7 @@ function syncModelMeta(yDoc, modelName, schema) {
1372
1436
  if (compoundConstraints.length > 0) {
1373
1437
  let constraints = meta.get("_constraints");
1374
1438
  if (!constraints) {
1375
- constraints = new Y3.Map();
1439
+ constraints = new Y4.Map();
1376
1440
  meta.set("_constraints", constraints);
1377
1441
  }
1378
1442
  for (const constraint of compoundConstraints) {
@@ -1383,19 +1447,18 @@ function syncModelMeta(yDoc, modelName, schema) {
1383
1447
  if (relationships && Object.keys(relationships).length > 0) {
1384
1448
  let rels = meta.get("_relationships");
1385
1449
  if (!rels) {
1386
- rels = new Y3.Map();
1450
+ rels = new Y4.Map();
1387
1451
  meta.set("_relationships", rels);
1388
1452
  }
1389
1453
  for (const [relName, relConfig] of Object.entries(relationships)) {
1390
1454
  syncRelationshipMeta(rels, relName, relConfig);
1391
1455
  }
1392
1456
  }
1393
- synced.add(modelName);
1394
1457
  }
1395
1458
  function syncFieldMeta(metaMap, fieldName, fieldOpts) {
1396
1459
  let fieldMeta = metaMap.get(fieldName);
1397
1460
  if (!fieldMeta) {
1398
- fieldMeta = new Y3.Map();
1461
+ fieldMeta = new Y4.Map();
1399
1462
  metaMap.set(fieldName, fieldMeta);
1400
1463
  }
1401
1464
  setIfChanged(fieldMeta, "type", fieldOpts.type);
@@ -1412,7 +1475,7 @@ function syncFieldMeta(metaMap, fieldName, fieldOpts) {
1412
1475
  function syncConstraintMeta(constraintsMap, constraint) {
1413
1476
  let cMeta = constraintsMap.get(constraint.name);
1414
1477
  if (!cMeta) {
1415
- cMeta = new Y3.Map();
1478
+ cMeta = new Y4.Map();
1416
1479
  constraintsMap.set(constraint.name, cMeta);
1417
1480
  }
1418
1481
  setIfChanged(cMeta, "type", "unique");
@@ -1422,7 +1485,7 @@ function syncConstraintMeta(constraintsMap, constraint) {
1422
1485
  function syncRelationshipMeta(relsMap, relName, relConfig) {
1423
1486
  let relMeta = relsMap.get(relName);
1424
1487
  if (!relMeta) {
1425
- relMeta = new Y3.Map();
1488
+ relMeta = new Y4.Map();
1426
1489
  relsMap.set(relName, relMeta);
1427
1490
  }
1428
1491
  for (const [key, value] of Object.entries(relConfig)) {
@@ -1437,7 +1500,7 @@ function syncInferredMeta(yDoc, modelName, recordData) {
1437
1500
  if (fieldName.startsWith("_")) continue;
1438
1501
  let fieldMeta = meta.get(fieldName);
1439
1502
  if (!fieldMeta) {
1440
- fieldMeta = new Y3.Map();
1503
+ fieldMeta = new Y4.Map();
1441
1504
  meta.set(fieldName, fieldMeta);
1442
1505
  }
1443
1506
  if (!fieldMeta.has("type")) {
@@ -1453,44 +1516,19 @@ function setIfChanged(map, key, value) {
1453
1516
  map.set(key, value);
1454
1517
  }
1455
1518
  }
1456
- var Y3, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
1519
+ var Y4, KNOWN_FUNCTION_DEFAULTS, _syncedCache;
1457
1520
  var init_metaSync = __esm({
1458
1521
  "src/models/metaSync.ts"() {
1459
1522
  "use strict";
1460
- Y3 = __toESM(require("yjs"), 1);
1523
+ Y4 = __toESM(require("yjs"), 1);
1461
1524
  init_yjsValues();
1525
+ init_uniqueIndex();
1462
1526
  init_writeLifecycle();
1463
1527
  KNOWN_FUNCTION_DEFAULTS = /* @__PURE__ */ new WeakMap();
1464
1528
  _syncedCache = /* @__PURE__ */ new WeakMap();
1465
1529
  }
1466
1530
  });
1467
1531
 
1468
- // src/utils/uniqueIndex.ts
1469
- function buildUniqueKey(recordData, fields) {
1470
- const keyParts = [];
1471
- for (const field of fields) {
1472
- const value = recordData instanceof Y4.Map ? recordData.get(field) : recordData[field];
1473
- if (value === null || value === void 0) {
1474
- return null;
1475
- }
1476
- keyParts.push(String(value));
1477
- }
1478
- return keyParts.join("|");
1479
- }
1480
- function uniqueIndexMapName(modelName, constraintName) {
1481
- return `_uniqueIdx_${modelName}_${constraintName}`;
1482
- }
1483
- function fieldUniqueConstraintName(modelName, fieldName) {
1484
- return `${modelName}_${fieldName}_unique`;
1485
- }
1486
- var Y4;
1487
- var init_uniqueIndex = __esm({
1488
- "src/utils/uniqueIndex.ts"() {
1489
- "use strict";
1490
- Y4 = __toESM(require("yjs"), 1);
1491
- }
1492
- });
1493
-
1494
1532
  // src/utils/largeDocuments.ts
1495
1533
  function encodeOverlaySegment(segment) {
1496
1534
  const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -1767,15 +1805,344 @@ var init_format2OfflineWindow = __esm({
1767
1805
  overdueMs;
1768
1806
  constructor(documentId, status) {
1769
1807
  super(
1770
- `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.`
1808
+ `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.`
1809
+ );
1810
+ this.name = "DocumentOfflineWindowError";
1811
+ this.documentId = documentId;
1812
+ this.lastSyncAt = status.lastSyncAt;
1813
+ this.windowDays = status.windowDays;
1814
+ this.overdueMs = status.overdueMs;
1815
+ }
1816
+ };
1817
+ }
1818
+ });
1819
+
1820
+ // src/utils/snapshotChunks.ts
1821
+ function parseSnapshotChunkPath(path) {
1822
+ if (!path || typeof path !== "string") return null;
1823
+ const own = CHUNK_PATH_OWN.exec(path);
1824
+ if (own) return { model: own[1], index: Number(own[2]) };
1825
+ const sourced = CHUNK_PATH_SOURCED.exec(path);
1826
+ if (!sourced) return null;
1827
+ return {
1828
+ model: sourced[3],
1829
+ index: Number(sourced[4]),
1830
+ source: { epoch: Number(sourced[1]), buildId: sourced[2] }
1831
+ };
1832
+ }
1833
+ function resolveChunkOrdinal(entry, index) {
1834
+ return typeof entry.ordinal === "number" ? entry.ordinal : index;
1835
+ }
1836
+ function collationRank(unit) {
1837
+ if (unit >= 57344) return unit - 2048;
1838
+ if (unit >= 55296) return unit + 8192;
1839
+ return unit;
1840
+ }
1841
+ function compareRecordIds(left, right) {
1842
+ if (left === right) return 0;
1843
+ const shared = Math.min(left.length, right.length);
1844
+ for (let index = 0; index < shared; index++) {
1845
+ const a = left.charCodeAt(index);
1846
+ const b = right.charCodeAt(index);
1847
+ if (a === b) continue;
1848
+ return collationRank(a) < collationRank(b) ? -1 : 1;
1849
+ }
1850
+ return left.length < right.length ? -1 : left.length > right.length ? 1 : 0;
1851
+ }
1852
+ function validateSnapshotManifest(manifest) {
1853
+ if (!manifest || typeof manifest !== "object") {
1854
+ throw new SnapshotManifestInvalidError("it is not an object");
1855
+ }
1856
+ const kind = manifest.kind;
1857
+ if (kind !== void 0 && kind !== "snapshot") {
1858
+ throw new SnapshotManifestInvalidError(
1859
+ `it is a ${String(kind)} manifest, not a snapshot manifest`
1860
+ );
1861
+ }
1862
+ const version = manifest.version;
1863
+ if (!Number.isInteger(version)) {
1864
+ throw new SnapshotManifestInvalidError(`version ${String(version)} is not an integer`);
1865
+ }
1866
+ if (version > FORMAT2_MANIFEST_VERSION) {
1867
+ throw new SnapshotManifestUnsupportedError(version);
1868
+ }
1869
+ if (version < FORMAT2_MIN_MANIFEST_VERSION) {
1870
+ throw new SnapshotManifestInvalidError(
1871
+ `version ${version} is older than the large-document format (${FORMAT2_MIN_MANIFEST_VERSION})`
1872
+ );
1873
+ }
1874
+ const chunks = manifest.chunks;
1875
+ if (!Array.isArray(chunks)) {
1876
+ throw new SnapshotManifestInvalidError("its chunk list is not an array");
1877
+ }
1878
+ const seenOrdinals = /* @__PURE__ */ new Set();
1879
+ const lastRangeEndPerModel = /* @__PURE__ */ new Map();
1880
+ let rows = 0;
1881
+ let bytes = 0;
1882
+ for (let index = 0; index < chunks.length; index++) {
1883
+ const entry = chunks[index];
1884
+ const where = `chunk ${index} (${entry?.model ?? "?"})`;
1885
+ if (!entry || typeof entry !== "object") {
1886
+ throw new SnapshotManifestInvalidError(`${where} is not an object`);
1887
+ }
1888
+ if (entry.ordinal !== void 0 && !Number.isSafeInteger(entry.ordinal)) {
1889
+ throw new SnapshotManifestInvalidError(
1890
+ `${where} has ordinal ${String(entry.ordinal)}, which is not an integer`
1891
+ );
1892
+ }
1893
+ const ordinal = resolveChunkOrdinal(entry, index);
1894
+ if (!Number.isSafeInteger(ordinal) || ordinal < 0) {
1895
+ throw new SnapshotManifestInvalidError(
1896
+ `${where} resolves to ordinal ${String(ordinal)}, which is not a non-negative integer`
1897
+ );
1898
+ }
1899
+ if (seenOrdinals.has(ordinal)) {
1900
+ throw new SnapshotManifestInvalidError(
1901
+ `${where} shares ordinal ${ordinal} with an earlier chunk`
1902
+ );
1903
+ }
1904
+ seenOrdinals.add(ordinal);
1905
+ if (entry.path !== void 0 && !parseSnapshotChunkPath(entry.path)) {
1906
+ throw new SnapshotManifestInvalidError(
1907
+ `${where} has a path that is not addressable: ${String(entry.path)}`
1908
+ );
1909
+ }
1910
+ if (entry.rows > 0 && compareRecordIds(entry.firstId, entry.lastId) > 0) {
1911
+ throw new SnapshotManifestInvalidError(
1912
+ `${where} spans ${entry.firstId}..${entry.lastId}, which runs backwards`
1913
+ );
1914
+ }
1915
+ if (entry.rows > 0) {
1916
+ const previousEnd = lastRangeEndPerModel.get(entry.model);
1917
+ if (previousEnd !== void 0 && compareRecordIds(entry.firstId, previousEnd) <= 0) {
1918
+ throw new SnapshotManifestInvalidError(
1919
+ `${where} starts at ${entry.firstId}, at or before ${previousEnd} where the previous chunk of ${entry.model} ended`
1920
+ );
1921
+ }
1922
+ lastRangeEndPerModel.set(entry.model, entry.lastId);
1923
+ }
1924
+ rows += Number(entry.rows) || 0;
1925
+ bytes += Number(entry.bytes) || 0;
1926
+ }
1927
+ const totals = manifest.totals;
1928
+ if (!totals || totals.rows !== rows || totals.bytes !== bytes) {
1929
+ throw new SnapshotManifestInvalidError(
1930
+ `its totals say ${totals?.rows}/${totals?.bytes} rows/bytes but its chunks add up to ${rows}/${bytes}`
1931
+ );
1932
+ }
1933
+ validateIngestInfo(manifest.ingest);
1934
+ }
1935
+ function validateIngestInfo(ingest) {
1936
+ if (ingest === void 0) return;
1937
+ if (!ingest || typeof ingest !== "object" || Array.isArray(ingest)) {
1938
+ throw new SnapshotManifestInvalidError("its ingest block is not an object");
1939
+ }
1940
+ if (typeof ingest.sessionId !== "string" || ingest.sessionId.length === 0) {
1941
+ throw new SnapshotManifestInvalidError(
1942
+ `its ingest block names session ${String(ingest.sessionId)}`
1943
+ );
1944
+ }
1945
+ if (!Number.isSafeInteger(ingest.epoch) || ingest.epoch <= 0) {
1946
+ throw new SnapshotManifestInvalidError(
1947
+ `its ingest block names epoch ${String(ingest.epoch)}, which is not an epoch`
1948
+ );
1949
+ }
1950
+ if (!Array.isArray(ingest.ranges)) {
1951
+ throw new SnapshotManifestInvalidError(
1952
+ "its ingest block's range list is not an array"
1953
+ );
1954
+ }
1955
+ const lastPerModel = /* @__PURE__ */ new Map();
1956
+ for (let index = 0; index < ingest.ranges.length; index++) {
1957
+ const range = ingest.ranges[index];
1958
+ const where = `ingest range ${index} (${range?.model ?? "?"})`;
1959
+ if (!range || typeof range !== "object") {
1960
+ throw new SnapshotManifestInvalidError(`${where} is not an object`);
1961
+ }
1962
+ if (typeof range.model !== "string" || !MODEL_NAME.test(range.model)) {
1963
+ throw new SnapshotManifestInvalidError(
1964
+ `${where} names ${String(range.model)}, which is not a model name`
1965
+ );
1966
+ }
1967
+ if (typeof range.firstId !== "string" || typeof range.lastId !== "string") {
1968
+ throw new SnapshotManifestInvalidError(`${where} has no id bounds`);
1969
+ }
1970
+ if (compareRecordIds(range.firstId, range.lastId) > 0) {
1971
+ throw new SnapshotManifestInvalidError(
1972
+ `${where} spans ${range.firstId}..${range.lastId}, which runs backwards`
1973
+ );
1974
+ }
1975
+ const previous = lastPerModel.get(range.model);
1976
+ if (previous !== void 0 && compareRecordIds(range.firstId, previous) <= 0) {
1977
+ throw new SnapshotManifestInvalidError(
1978
+ `${where} starts at ${range.firstId}, at or before ${previous} where the previous range of ${range.model} ended`
1979
+ );
1980
+ }
1981
+ lastPerModel.set(range.model, range.lastId);
1982
+ }
1983
+ }
1984
+ function snapshotStringSetFields(manifest, model) {
1985
+ const declared = manifest.schema?.[model]?.stringSetFields;
1986
+ return new Set(Array.isArray(declared) ? declared.map(String) : []);
1987
+ }
1988
+ function snapshotRowAsOverlayEntry(id, data, stringSetFields) {
1989
+ const fields = {};
1990
+ const stringSets = {};
1991
+ for (const [field, value] of Object.entries(data)) {
1992
+ if (field === "id") continue;
1993
+ if (stringSetFields.has(field) && Array.isArray(value)) {
1994
+ const members = {};
1995
+ for (const member of value) members[String(member)] = true;
1996
+ stringSets[field] = members;
1997
+ continue;
1998
+ }
1999
+ fields[field] = value;
2000
+ }
2001
+ return { id, fields, stringSets, replace: true, deleted: false };
2002
+ }
2003
+ async function gunzip(bytes) {
2004
+ return await collect(
2005
+ new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"))
2006
+ );
2007
+ }
2008
+ async function collect(stream) {
2009
+ const parts = [];
2010
+ let total = 0;
2011
+ const reader = stream.getReader();
2012
+ for (; ; ) {
2013
+ const { done, value } = await reader.read();
2014
+ if (done) break;
2015
+ parts.push(value);
2016
+ total += value.byteLength;
2017
+ }
2018
+ const out = new Uint8Array(total);
2019
+ let at = 0;
2020
+ for (const part of parts) {
2021
+ out.set(part, at);
2022
+ at += part.byteLength;
2023
+ }
2024
+ return out;
2025
+ }
2026
+ async function sha256Hex(bytes) {
2027
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
2028
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
2029
+ }
2030
+ async function readSnapshotChunk(body, descriptor) {
2031
+ const entry = "sha256" in descriptor ? descriptor : null;
2032
+ if (entry) {
2033
+ if (body.byteLength !== entry.bytes) {
2034
+ throw new SnapshotChunkIntegrityError(
2035
+ entry.key,
2036
+ `expected ${entry.bytes} bytes, got ${body.byteLength}`
2037
+ );
2038
+ }
2039
+ const digest = await sha256Hex(body);
2040
+ if (digest !== entry.sha256) {
2041
+ throw new SnapshotChunkIntegrityError(
2042
+ entry.key,
2043
+ `expected sha256 ${entry.sha256}, got ${digest}`
2044
+ );
2045
+ }
2046
+ }
2047
+ const text = decoder.decode(await gunzip(body));
2048
+ const rows = [];
2049
+ for (const line of text.split("\n")) {
2050
+ if (line.length === 0) continue;
2051
+ const tab = line.indexOf(" ");
2052
+ if (tab < 0) {
2053
+ throw new SnapshotChunkIntegrityError(
2054
+ entry?.key ?? descriptor.model,
2055
+ "a line carries no id/data separator"
2056
+ );
2057
+ }
2058
+ rows.push({
2059
+ _type: descriptor.model,
2060
+ _id: line.slice(0, tab),
2061
+ _data: line.slice(tab + 1)
2062
+ });
2063
+ }
2064
+ if (entry && rows.length !== entry.rows) {
2065
+ throw new SnapshotChunkIntegrityError(
2066
+ entry.key,
2067
+ `expected ${entry.rows} rows, got ${rows.length}`
2068
+ );
2069
+ }
2070
+ if (entry && rows.length > 0) {
2071
+ const seen = /* @__PURE__ */ new Set();
2072
+ for (const row of rows) {
2073
+ if (seen.has(row._id)) {
2074
+ throw new SnapshotChunkIntegrityError(
2075
+ entry.key,
2076
+ `duplicate id ${row._id}`
2077
+ );
2078
+ }
2079
+ seen.add(row._id);
2080
+ }
2081
+ if (rows[0]._id !== entry.firstId) {
2082
+ throw new SnapshotChunkIntegrityError(
2083
+ entry.key,
2084
+ `expected it to begin at ${entry.firstId}, got ${rows[0]._id}`
2085
+ );
2086
+ }
2087
+ if (rows[rows.length - 1]._id !== entry.lastId) {
2088
+ throw new SnapshotChunkIntegrityError(
2089
+ entry.key,
2090
+ `expected it to end at ${entry.lastId}, got ${rows[rows.length - 1]._id}`
2091
+ );
2092
+ }
2093
+ }
2094
+ return rows;
2095
+ }
2096
+ var FORMAT2_MANIFEST_VERSION, FORMAT2_MIN_MANIFEST_VERSION, INGEST_CHUNK_LIMITS, SnapshotChunkIntegrityError, SnapshotManifestInvalidError, SnapshotManifestUnsupportedError, CHUNK_PATH_OWN, CHUNK_PATH_SOURCED, MODEL_NAME, encoder, decoder, SNAPSHOT_ENCODE_SEGMENT_BYTES;
2097
+ var init_snapshotChunks = __esm({
2098
+ "src/utils/snapshotChunks.ts"() {
2099
+ "use strict";
2100
+ FORMAT2_MANIFEST_VERSION = 3;
2101
+ FORMAT2_MIN_MANIFEST_VERSION = 2;
2102
+ INGEST_CHUNK_LIMITS = {
2103
+ /** Stored (compressed) bytes of one chunk. */
2104
+ maxChunkBytes: 8 * 1024 * 1024,
2105
+ /** Decompressed bytes of one chunk — the bound the reader stops at. */
2106
+ maxChunkRawBytes: 16 * 1024 * 1024,
2107
+ /** Lines in one chunk. */
2108
+ maxChunkRows: 5e4,
2109
+ /** Chunks in one session. */
2110
+ maxChunks: 4096,
2111
+ /** Stored bytes across the whole session. */
2112
+ maxTotalBytes: 2 * 1024 * 1024 * 1024
2113
+ };
2114
+ SnapshotChunkIntegrityError = class extends Error {
2115
+ constructor(key, reason) {
2116
+ super(`Snapshot chunk ${key} does not match its manifest entry: ${reason}`);
2117
+ this.key = key;
2118
+ this.reason = reason;
2119
+ this.name = "SnapshotChunkIntegrityError";
2120
+ }
2121
+ };
2122
+ SnapshotManifestInvalidError = class extends Error {
2123
+ code = "SNAPSHOT_MANIFEST_INVALID";
2124
+ constructor(reason) {
2125
+ super(`Snapshot manifest is not valid: ${reason}`);
2126
+ this.name = "SnapshotManifestInvalidError";
2127
+ }
2128
+ };
2129
+ SnapshotManifestUnsupportedError = class extends Error {
2130
+ constructor(manifestVersion, supportedVersion = FORMAT2_MANIFEST_VERSION) {
2131
+ super(
2132
+ `Snapshot manifest is version ${manifestVersion}; this client reads up to version ${supportedVersion}. Upgrade the client library.`
1771
2133
  );
1772
- this.name = "DocumentOfflineWindowError";
1773
- this.documentId = documentId;
1774
- this.lastSyncAt = status.lastSyncAt;
1775
- this.windowDays = status.windowDays;
1776
- this.overdueMs = status.overdueMs;
2134
+ this.manifestVersion = manifestVersion;
2135
+ this.supportedVersion = supportedVersion;
2136
+ this.name = "SnapshotManifestUnsupportedError";
1777
2137
  }
2138
+ code = "SNAPSHOT_MANIFEST_UNSUPPORTED";
1778
2139
  };
2140
+ CHUNK_PATH_OWN = /^([A-Za-z][A-Za-z0-9_]*)\/(\d+)$/;
2141
+ CHUNK_PATH_SOURCED = /^(\d+)-([A-Za-z0-9_-]{1,64})\/([A-Za-z][A-Za-z0-9_]*)\/(\d+)$/;
2142
+ MODEL_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
2143
+ encoder = new TextEncoder();
2144
+ decoder = new TextDecoder();
2145
+ SNAPSHOT_ENCODE_SEGMENT_BYTES = 64 * 1024;
1779
2146
  }
1780
2147
  });
1781
2148
 
@@ -1918,6 +2285,28 @@ function format2ClientDDL(docId) {
1918
2285
  build_id TEXT NOT NULL,
1919
2286
  ordinal INTEGER NOT NULL,
1920
2287
  PRIMARY KEY (doc_id, build_id, ordinal)
2288
+ )`,
2289
+ // #3435 — which base this merged view is on, and whether its load ever
2290
+ // FINISHED. The second half is what makes a partial replacement safe: a
2291
+ // convergence may keep a chunk by marking it rather than fetching it only
2292
+ // when the rows it is keeping are a complete baseline. A client that is
2293
+ // cold, or whose last load was interrupted, has no such guarantee and
2294
+ // takes the full plan (finding 3435-R04).
2295
+ `CREATE TABLE IF NOT EXISTS _snapshot_base (
2296
+ doc_id TEXT PRIMARY KEY,
2297
+ build_id TEXT NOT NULL,
2298
+ epoch INTEGER NOT NULL,
2299
+ complete INTEGER NOT NULL DEFAULT 0
2300
+ )`,
2301
+ // #3435 — every bulk-load epoch this document crossed and has not yet
2302
+ // converged past. Durable, because a connected client that followed a
2303
+ // flagged seal onto E+1 has an epoch mark that reads perfectly current:
2304
+ // if the debt lived only in memory, a restart would clear it and the
2305
+ // client would serve rows the server replaced, for good.
2306
+ `CREATE TABLE IF NOT EXISTS _discontinuities (
2307
+ doc_id TEXT NOT NULL,
2308
+ epoch INTEGER NOT NULL,
2309
+ PRIMARY KEY (doc_id, epoch)
1921
2310
  )`
1922
2311
  ];
1923
2312
  }
@@ -2752,6 +3141,185 @@ var init_format2RecordStore = __esm({
2752
3141
  ordinal
2753
3142
  );
2754
3143
  }
3144
+ // ── The base this view is on, and the bulk loads it owes (#3435) ───
3145
+ /**
3146
+ * Which base the merged view is on, and which bulk-load epochs it has
3147
+ * crossed and not yet converged past.
3148
+ *
3149
+ * One read, because the two are one decision: a convergence may keep a
3150
+ * chunk by marking it only when the base it is keeping rows from is
3151
+ * complete AND exactly one discontinuity stands between it and the
3152
+ * announced base.
3153
+ */
3154
+ baseState() {
3155
+ const rows = this.rows(
3156
+ "SELECT build_id, epoch, complete FROM _snapshot_base WHERE doc_id = ?",
3157
+ this.docId
3158
+ );
3159
+ return {
3160
+ base: rows.length > 0 ? {
3161
+ buildId: String(rows[0].build_id),
3162
+ epoch: Number(rows[0].epoch),
3163
+ complete: Boolean(Number(rows[0].complete))
3164
+ } : null,
3165
+ discontinuities: this.discontinuityEpochs()
3166
+ };
3167
+ }
3168
+ /** Every unconverged bulk-load epoch, ascending. */
3169
+ discontinuityEpochs() {
3170
+ return this.rows(
3171
+ "SELECT epoch FROM _discontinuities WHERE doc_id = ? ORDER BY epoch",
3172
+ this.docId
3173
+ ).map((row) => Number(row.epoch));
3174
+ }
3175
+ /**
3176
+ * Start loading `buildId` as this view's base.
3177
+ *
3178
+ * The load marks of OTHER builds go in the same transaction. They are
3179
+ * progress on a load nothing will ever finish, and a device that later came
3180
+ * back to that build would skip chunks whose rows this base has replaced.
3181
+ * One transaction because the row and the marks describe one thing: what
3182
+ * this view is being made into.
3183
+ */
3184
+ beginBase(buildId, epoch) {
3185
+ this.sql.transaction(() => {
3186
+ this.sql.exec(
3187
+ `INSERT INTO _snapshot_base (doc_id, build_id, epoch, complete)
3188
+ VALUES (?, ?, ?, 0)
3189
+ ON CONFLICT(doc_id) DO UPDATE SET build_id = ?, epoch = ?, complete = 0`,
3190
+ this.docId,
3191
+ buildId,
3192
+ epoch,
3193
+ buildId,
3194
+ epoch
3195
+ );
3196
+ this.sql.exec(
3197
+ "DELETE FROM _snapshot_load WHERE doc_id = ? AND build_id <> ?",
3198
+ this.docId,
3199
+ buildId
3200
+ );
3201
+ });
3202
+ }
3203
+ /** The load of `buildId` finished: this view is a complete baseline. */
3204
+ completeBase(buildId) {
3205
+ this.sql.exec(
3206
+ "UPDATE _snapshot_base SET complete = 1 WHERE doc_id = ? AND build_id = ?",
3207
+ this.docId,
3208
+ buildId
3209
+ );
3210
+ }
3211
+ /** Record a bulk-load epoch this document crossed. Idempotent. */
3212
+ noteDiscontinuity(epoch) {
3213
+ this.sql.exec(
3214
+ "INSERT OR IGNORE INTO _discontinuities (doc_id, epoch) VALUES (?, ?)",
3215
+ this.docId,
3216
+ epoch
3217
+ );
3218
+ }
3219
+ /** Forget every crossed epoch at or below `through`: converged past them. */
3220
+ clearDiscontinuities(through) {
3221
+ this.sql.exec(
3222
+ "DELETE FROM _discontinuities WHERE doc_id = ? AND epoch <= ?",
3223
+ this.docId,
3224
+ through
3225
+ );
3226
+ }
3227
+ /**
3228
+ * Replace one chunk's COVERAGE with its rows, in one commit (#3435,
3229
+ * behavior 11; finding 3435-R01).
3230
+ *
3231
+ * The atomic range replacement the design doc requires, and the reason it
3232
+ * is one store method rather than a delete followed by {@link applyChunk}.
3233
+ * Two tabs share this database and each run their own loader over the same
3234
+ * plan. With the delete committed apart from the insert, this interleaving
3235
+ * loses the chunk permanently: the first holder deletes, inserts and marks;
3236
+ * the second then deletes the same coverage and its `applyChunk` skips the
3237
+ * insert because the chunk is already marked. The rows are gone under a mark
3238
+ * that says they landed, and nothing ever fetches them again.
3239
+ *
3240
+ * So the mark check, the coverage delete, the insert and the mark are one
3241
+ * transaction, and a chunk another holder already landed changes nothing.
3242
+ *
3243
+ * The bounds are `(after, through]`, a null bound open, compared by the
3244
+ * TABLE's collation. That is SQLite's `BINARY` — UTF-8 byte order — which is
3245
+ * what `compareRecordIds` models and what the manifest's ranges were cut in.
3246
+ * A JavaScript `<` would disagree with both wherever an id mixes a
3247
+ * three-byte character with a supplementary one.
3248
+ */
3249
+ replaceChunk(modelName, coverage, entries, buildId, ordinal) {
3250
+ let applied = 0;
3251
+ let removed = 0;
3252
+ let skipped = false;
3253
+ this.sql.transaction(() => {
3254
+ if (this.chunkIsComplete(buildId, ordinal)) {
3255
+ skipped = true;
3256
+ return;
3257
+ }
3258
+ if (!this.isHydrated(modelName)) {
3259
+ return;
3260
+ }
3261
+ removed = this.deleteCoverage(modelName, coverage);
3262
+ for (const entry of entries) {
3263
+ projectOverlayEntry(this.sql, modelName, entry, this.tables);
3264
+ applied++;
3265
+ }
3266
+ this.markChunkComplete(buildId, ordinal);
3267
+ });
3268
+ return { applied, removed, skipped };
3269
+ }
3270
+ /**
3271
+ * Delete up to `limit` rows of one model's range, and say whether that was
3272
+ * the last of them (#3435, behavior 11a).
3273
+ *
3274
+ * Paged because pruning a whole model of a multi-hundred-megabyte document
3275
+ * in one statement is the invocation-length failure the rest of this design
3276
+ * avoids; idempotent between two holders because it only ever deletes what
3277
+ * is there. Used for a whole-model prune — a model the new base does not
3278
+ * carry at all — and nothing else: a chunk's range is replaced by
3279
+ * {@link replaceChunk}, atomically, for the reason above.
3280
+ */
3281
+ deleteRange(modelName, after, through, limit) {
3282
+ if (!this.isHydrated(modelName)) return { removed: 0, done: true };
3283
+ let removed = 0;
3284
+ this.sql.transaction(() => {
3285
+ removed = this.deleteCoverage(modelName, { after, through }, limit);
3286
+ });
3287
+ return { removed, done: removed < limit };
3288
+ }
3289
+ /**
3290
+ * Delete a model's rows over `(after, through]` and their stringset
3291
+ * members, up to `limit` rows. Callers hold the transaction.
3292
+ */
3293
+ deleteCoverage(modelName, coverage, limit) {
3294
+ const bounds = [];
3295
+ const bindings = [modelName];
3296
+ if (coverage.after !== null) {
3297
+ bounds.push("_id > ?");
3298
+ bindings.push(coverage.after);
3299
+ }
3300
+ if (coverage.through !== null) {
3301
+ bounds.push("_id <= ?");
3302
+ bindings.push(coverage.through);
3303
+ }
3304
+ const where = ["_type = ?", ...bounds].join(" AND ");
3305
+ const doomed = this.rows(
3306
+ `SELECT _id FROM ${this.tables.records} WHERE ${where} ORDER BY _id` + (limit === void 0 ? "" : " LIMIT ?"),
3307
+ ...limit === void 0 ? bindings : [...bindings, limit]
3308
+ ).map((row) => String(row._id));
3309
+ for (const id of doomed) {
3310
+ this.sql.exec(
3311
+ `DELETE FROM ${this.tables.stringSetIndex} WHERE _type = ? AND _record_id = ?`,
3312
+ modelName,
3313
+ id
3314
+ );
3315
+ this.sql.exec(
3316
+ `DELETE FROM ${this.tables.records} WHERE _type = ? AND _id = ?`,
3317
+ modelName,
3318
+ id
3319
+ );
3320
+ }
3321
+ return doomed.length;
3322
+ }
2755
3323
  /**
2756
3324
  * Throw away a merged view that cannot be repaired (#2816, behavior 12).
2757
3325
  *
@@ -2773,6 +3341,7 @@ var init_format2RecordStore = __esm({
2773
3341
  this.sql.exec(`DELETE FROM ${this.tables.records}`);
2774
3342
  this.sql.exec(`DELETE FROM ${this.tables.stringSetIndex}`);
2775
3343
  this.sql.exec("DELETE FROM _snapshot_load WHERE doc_id = ?", this.docId);
3344
+ this.sql.exec("DELETE FROM _snapshot_base WHERE doc_id = ?", this.docId);
2776
3345
  });
2777
3346
  }
2778
3347
  // ── helpers ───────────────────────────────────────────────────────
@@ -3008,6 +3577,7 @@ var init_format2RecordStoreAsync = __esm({
3008
3577
  init_BaseModel();
3009
3578
  init_documentTypes();
3010
3579
  init_format2OfflineWindow();
3580
+ init_snapshotChunks();
3011
3581
  init_format2RecordStore();
3012
3582
  init_format2RowCache();
3013
3583
  init_format2Observer();
@@ -3346,6 +3916,23 @@ var init_format2RecordStoreAsync = __esm({
3346
3916
  const store = this.direct();
3347
3917
  return store ? store.hydrationScope() : this.mirrored().hydrationScope;
3348
3918
  }
3919
+ /**
3920
+ * The bulk-load epochs this document crossed and has not converged past
3921
+ * (#3435), ascending.
3922
+ *
3923
+ * Synchronous because the transport asks it at the bind, in the same breath
3924
+ * as the epoch mark, to decide whether the handshake it is holding means a
3925
+ * catch-up or a convergence.
3926
+ */
3927
+ discontinuityEpochs() {
3928
+ const store = this.direct();
3929
+ return store ? store.discontinuityEpochs() : [...this.mirrored().discontinuityEpochs];
3930
+ }
3931
+ /** Whether the base this view is on finished loading (#3435). */
3932
+ baseComplete() {
3933
+ const store = this.direct();
3934
+ return store ? store.baseState().base?.complete ?? false : this.mirrored().baseComplete;
3935
+ }
3349
3936
  isHydrated(model) {
3350
3937
  const scope = this.hydrationScope();
3351
3938
  return scope === null || scope.includes(model);
@@ -3566,6 +4153,122 @@ var init_format2RecordStoreAsync = __esm({
3566
4153
  completedChunks(buildId) {
3567
4154
  return this.host.dispatch({ kind: "completedChunks", buildId });
3568
4155
  }
4156
+ /**
4157
+ * Replace one chunk's coverage with its rows, atomically (#3435).
4158
+ *
4159
+ * The live cache entries the write could have moved are re-read afterwards:
4160
+ * the command DELETES a range as well as inserting, so an entry inside that
4161
+ * range may name a record that no longer exists, and the ids it removed do
4162
+ * not cross the boundary.
4163
+ *
4164
+ * The range is the bound, not the whole cache. A convergence replaces named
4165
+ * ranges of named models precisely so that everything else keeps its rows —
4166
+ * including the chunks it kept, which are the saving the design exists for —
4167
+ * and a blanket invalidation would tell every live instance of every model
4168
+ * that its record is gone, with `loaded` set so nothing reads it back
4169
+ * (finding 3435-C03).
4170
+ */
4171
+ async replaceChunk(model, coverage, entries, buildId, ordinal) {
4172
+ const reply = await this.run(
4173
+ "replaceChunk",
4174
+ () => this.host.dispatch({
4175
+ kind: "replaceChunk",
4176
+ model,
4177
+ coverage,
4178
+ entries: [...entries],
4179
+ buildId,
4180
+ ordinal
4181
+ })
4182
+ );
4183
+ if (!reply.skipped) {
4184
+ await this.refreshRange(
4185
+ model,
4186
+ coverage,
4187
+ entries.map((entry) => entry.id)
4188
+ );
4189
+ }
4190
+ return reply;
4191
+ }
4192
+ /** One page of a whole-model prune (#3435). */
4193
+ async deleteRange(model, after, through, limit) {
4194
+ const reply = await this.run(
4195
+ "deleteRange",
4196
+ () => this.host.dispatch({ kind: "deleteRange", model, after, through, limit })
4197
+ );
4198
+ if (reply.removed > 0) {
4199
+ await this.refreshRange(model, { after, through }, []);
4200
+ }
4201
+ return reply;
4202
+ }
4203
+ /**
4204
+ * Re-read the live entries a range write could have moved (#3435).
4205
+ *
4206
+ * One model, one range, plus whatever the write inserted — a record outside
4207
+ * both is exactly what the plan kept, and its instances go on answering. The
4208
+ * bound is `compareRecordIds`, the collation the store deleted the range
4209
+ * under, so the two agree about which ids the write covered.
4210
+ */
4211
+ async refreshRange(model, coverage, written) {
4212
+ const covered = (id) => (coverage.after === null || compareRecordIds(id, coverage.after) > 0) && (coverage.through === null || compareRecordIds(id, coverage.through) <= 0);
4213
+ const ids = new Set(this.cache.liveIds(model).filter(covered));
4214
+ for (const id of written) {
4215
+ if (this.cache.peek(model, id)) ids.add(id);
4216
+ }
4217
+ if (ids.size === 0) return;
4218
+ await this.refreshRows(model, [...ids]);
4219
+ }
4220
+ /** Which base this view is on, and which bulk loads it owes (#3435). */
4221
+ baseState() {
4222
+ return this.host.dispatch({ kind: "baseState" });
4223
+ }
4224
+ async beginBase(buildId, epoch) {
4225
+ await this.run(
4226
+ "beginBase",
4227
+ () => this.host.dispatch({ kind: "beginBase", buildId, epoch })
4228
+ );
4229
+ this.refreshMirror({ baseComplete: false });
4230
+ }
4231
+ async completeBase(buildId) {
4232
+ await this.run(
4233
+ "completeBase",
4234
+ () => this.host.dispatch({ kind: "completeBase", buildId })
4235
+ );
4236
+ this.refreshMirror({ baseComplete: true });
4237
+ }
4238
+ async noteDiscontinuity(epoch) {
4239
+ await this.run(
4240
+ "noteDiscontinuity",
4241
+ () => this.host.dispatch({ kind: "noteDiscontinuity", epoch })
4242
+ );
4243
+ const held = this.mirror?.discontinuityEpochs;
4244
+ if (held && !held.includes(epoch)) {
4245
+ this.refreshMirror({
4246
+ discontinuityEpochs: [...held, epoch].sort((a, b) => a - b)
4247
+ });
4248
+ }
4249
+ }
4250
+ async clearDiscontinuities(through) {
4251
+ await this.run(
4252
+ "clearDiscontinuities",
4253
+ () => this.host.dispatch({ kind: "clearDiscontinuities", through })
4254
+ );
4255
+ const held = this.mirror?.discontinuityEpochs;
4256
+ if (held) {
4257
+ this.refreshMirror({
4258
+ discontinuityEpochs: held.filter((epoch) => epoch > through)
4259
+ });
4260
+ }
4261
+ }
4262
+ /**
4263
+ * Keep the synchronous mirror in step with a durable change.
4264
+ *
4265
+ * A no-op where the store runs in this thread: there the store IS the
4266
+ * mirror, and a copy would go stale the moment anything wrote through it.
4267
+ */
4268
+ refreshMirror(patch) {
4269
+ if (!this.mirror) return;
4270
+ this.mirror = { ...this.mirror, ...patch };
4271
+ }
3569
4272
  async discardMergedView() {
3570
4273
  await this.run(
3571
4274
  "discardMergedView",
@@ -4029,7 +4732,11 @@ function readFormat2StoreMarks(store) {
4029
4732
  clockOffset: store.clockOffset(),
4030
4733
  clockOffsetKnown: store.clockOffsetKnown(),
4031
4734
  hydrationScope: store.hydrationScope(),
4032
- pendingOps: store.pendingOps()
4735
+ pendingOps: store.pendingOps(),
4736
+ // #3435 — the two facts a document that crossed a bulk load has to know
4737
+ // at bind, before it decides what the handshake means.
4738
+ discontinuityEpochs: store.discontinuityEpochs(),
4739
+ baseComplete: store.baseState().base?.complete ?? false
4033
4740
  };
4034
4741
  }
4035
4742
  function dispatchFormat2StoreCommand(store, sql, command) {
@@ -4112,6 +4819,31 @@ function run(store, sql, command) {
4112
4819
  };
4113
4820
  case "completedChunks":
4114
4821
  return store.completedChunks(command.buildId);
4822
+ case "replaceChunk":
4823
+ return store.replaceChunk(
4824
+ command.model,
4825
+ command.coverage,
4826
+ command.entries,
4827
+ command.buildId,
4828
+ command.ordinal
4829
+ );
4830
+ case "deleteRange":
4831
+ return store.deleteRange(
4832
+ command.model,
4833
+ command.after,
4834
+ command.through,
4835
+ command.limit
4836
+ );
4837
+ case "baseState":
4838
+ return store.baseState();
4839
+ case "beginBase":
4840
+ return store.beginBase(command.buildId, command.epoch);
4841
+ case "completeBase":
4842
+ return store.completeBase(command.buildId);
4843
+ case "noteDiscontinuity":
4844
+ return store.noteDiscontinuity(command.epoch);
4845
+ case "clearDiscontinuities":
4846
+ return store.clearDiscontinuities(command.through);
4115
4847
  case "foreignClients":
4116
4848
  return store.foreignClientIds();
4117
4849
  case "adoptPendingOps":
@@ -4493,6 +5225,19 @@ var init_OrmContext = __esm({
4493
5225
  format2Documents = /* @__PURE__ */ new Map();
4494
5226
  /** Format-2 epoch moves in flight (#2816). */
4495
5227
  format2Transitions = /* @__PURE__ */ new Map();
5228
+ /**
5229
+ * This instance's document observers' database writes that have not finished
5230
+ * yet, by document id (#3023).
5231
+ *
5232
+ * A Yjs observer fires synchronously with the update but writes to the
5233
+ * database asynchronously, so an update that has been APPLIED to a document
5234
+ * is not yet QUERYABLE. Whoever needs the difference — an `open()` that
5235
+ * promises its caller the synced records — waits on
5236
+ * {@link whenDocumentIndexed}. Per instance, not process-wide: two clients
5237
+ * holding the same document id write to their own engines, and neither
5238
+ * should be held up by the other's writes.
5239
+ */
5240
+ documentIndexingWork = /* @__PURE__ */ new Map();
4496
5241
  /**
4497
5242
  * Who this ORM instance is in a format-2 store's unacknowledged-write log
4498
5243
  * (#3431).
@@ -4670,6 +5415,56 @@ var init_OrmContext = __esm({
4670
5415
  release();
4671
5416
  }
4672
5417
  }
5418
+ /**
5419
+ * Record one observer's asynchronous write of `docId` (#3023), so a caller
5420
+ * that needs the document queryable can wait for it. Failures are the
5421
+ * observer's own to log; a tracked promise never rejects, because the point
5422
+ * of the barrier is only to know when the write is over.
5423
+ *
5424
+ * A destroyed context tracks nothing: its observers are already inert, and a
5425
+ * torn-down instance must not leave a barrier that never drains.
5426
+ */
5427
+ trackDocumentIndexing(docId, work) {
5428
+ if (this.destroyed) return;
5429
+ let inFlight = this.documentIndexingWork.get(docId);
5430
+ if (!inFlight) {
5431
+ inFlight = /* @__PURE__ */ new Set();
5432
+ this.documentIndexingWork.set(docId, inFlight);
5433
+ }
5434
+ const pending = work.catch(() => {
5435
+ }).then(() => {
5436
+ inFlight.delete(pending);
5437
+ if (inFlight.size === 0 && this.documentIndexingWork.get(docId) === inFlight) {
5438
+ this.documentIndexingWork.delete(docId);
5439
+ }
5440
+ });
5441
+ inFlight.add(pending);
5442
+ }
5443
+ /**
5444
+ * Resolve once `docId`'s applied changes have reached this instance's query
5445
+ * tables (#3023). Answers `true` when the work drained, `false` when it gave
5446
+ * up waiting — the caller owns the logging, because this module holds no
5447
+ * runtime imports.
5448
+ *
5449
+ * An update applied to the Y.Doc is visible to a reader of the document
5450
+ * immediately, but the query tables only catch up when the observers' writes
5451
+ * land. An `open()` that waited for the initial sync waits for this too —
5452
+ * otherwise it resolves on the sync signal and the caller's first `query()`
5453
+ * sees a partly-written table.
5454
+ *
5455
+ * A write can enqueue more work (a nested map observed as it arrives), so
5456
+ * this drains in passes rather than once, and gives up after enough of them
5457
+ * that only a document under continuous change could still be busy — a
5458
+ * barrier is not a reason to hang.
5459
+ */
5460
+ async whenDocumentIndexed(docId) {
5461
+ for (let pass = 0; pass < 50; pass++) {
5462
+ const inFlight = this.documentIndexingWork.get(docId);
5463
+ if (!inFlight || inFlight.size === 0) return true;
5464
+ await Promise.all([...inFlight]);
5465
+ }
5466
+ return false;
5467
+ }
4673
5468
  /** Notify `subscribe()` callbacks registered on this context for `modelName`. */
4674
5469
  notifyListeners(modelName, log, onError) {
4675
5470
  const listeners = this.listenersMap.get(modelName);
@@ -4795,6 +5590,7 @@ var init_OrmContext = __esm({
4795
5590
  this.modelDocMappingChangedListeners.clear();
4796
5591
  this.format2Documents.clear();
4797
5592
  this.format2Transitions.clear();
5593
+ this.documentIndexingWork.clear();
4798
5594
  this.observedStringSetMaps = /* @__PURE__ */ new WeakSet();
4799
5595
  liveOrmContexts.delete(this);
4800
5596
  if (defaultOrmContext === this) {
@@ -5165,6 +5961,10 @@ var init_ModelRegistry = __esm({
5165
5961
  * undefined the legacy session semantics are kept exactly: the global
5166
5962
  * registry is SNAPSHOT at init time, so a model registered afterwards is not
5167
5963
  * silently adopted by an already-running instance.
5964
+ *
5965
+ * The one way a running instance's registry grows is {@link adoptModel},
5966
+ * behind `registerModel` on the init result (#3615): an EXPLICIT call on the
5967
+ * instance the app means, not a relaxation of the snapshot rule.
5168
5968
  */
5169
5969
  static createInstanceRegistry(modelClasses) {
5170
5970
  const registry = new _ModelRegistry();
@@ -5188,6 +5988,64 @@ var init_ModelRegistry = __esm({
5188
5988
  registry.setExplicitModelsForSession(modelClasses);
5189
5989
  return registry;
5190
5990
  }
5991
+ /**
5992
+ * The name `modelClass` may be adopted under on THIS instance, or a refusal
5993
+ * that names what to change (#3615).
5994
+ *
5995
+ * Both checks are made before anything is mutated, so a registration that
5996
+ * cannot go through leaves the registry exactly as it was.
5997
+ */
5998
+ assertAdoptable(modelClass, instanceId) {
5999
+ const schema = modelClass?.getSchema?.();
6000
+ const modelName = modelClass?.modelName ?? schema?.options?.name;
6001
+ if (!modelName || !schema?.options?.name || !schema?.resolvedUniqueConstraints) {
6002
+ throw new Error(
6003
+ `[initJsBao] Cannot register a model class with no name. Build it with defineModelSchema and attachAndRegisterModel (or createModelClass) so it carries a static modelName and a schema with resolvedUniqueConstraints.`
6004
+ );
6005
+ }
6006
+ const existing = this.models.get(modelName);
6007
+ if (existing && existing !== modelClass) {
6008
+ throw new Error(
6009
+ `[initJsBao] Model '${modelName}' is already registered on js-bao instance #${instanceId} with a different class. Register the existing class, or give the new model a different name.`
6010
+ );
6011
+ }
6012
+ return modelName;
6013
+ }
6014
+ /**
6015
+ * Adopt `modelClass` into THIS instance's registry at runtime (#3615).
6016
+ *
6017
+ * The counterpart to the snapshot rule on
6018
+ * {@link ModelRegistry.createInstanceRegistry}: an instance grows only where
6019
+ * the app says so, and the global singleton is deliberately not touched —
6020
+ * registering here makes the model visible to this instance and to no other.
6021
+ *
6022
+ * Returns `{ added: false }` when the very same class is already ACTIVE on
6023
+ * this instance, which is what makes a repeat `registerModel` a no-op rather
6024
+ * than a warning about overwriting. Active, not merely present: an instance
6025
+ * built with `models: []` carries the whole global registry in `models` with
6026
+ * nothing active, and a class only the backing snapshot holds has neither
6027
+ * been validated for this session nor bound to its context, so it still has
6028
+ * an adoption to go through.
6029
+ */
6030
+ adoptModel(modelClass, options = {}) {
6031
+ const modelName = this.assertAdoptable(modelClass, options.instanceId);
6032
+ if (this.getActiveModels().get(modelName) === modelClass) {
6033
+ return { added: false };
6034
+ }
6035
+ const candidate = new Map(this.getActiveModels());
6036
+ candidate.set(modelName, modelClass);
6037
+ this.validateModels(candidate);
6038
+ const schema = modelClass.getSchema?.();
6039
+ this.models.set(modelName, modelClass);
6040
+ if (schema?.options) this.modelOptions.set(modelName, schema.options);
6041
+ if (this.activeSessionModels) {
6042
+ this.activeSessionModels.set(modelName, modelClass);
6043
+ }
6044
+ Logger.debug(
6045
+ `[ModelRegistry] Model "${modelName}" adopted by instance #${options.instanceId} at runtime.`
6046
+ );
6047
+ return { added: true };
6048
+ }
5191
6049
  registerModel(modelClass, options, fields) {
5192
6050
  if (!options.name) {
5193
6051
  throw new Error(
@@ -5390,7 +6248,8 @@ var init_ModelRegistry = __esm({
5390
6248
  Logger.debug(`[ModelRegistry] Data removal complete for document ${docId}`);
5391
6249
  }
5392
6250
  // New method to initialize relationships
5393
- async initializeRelationships() {
6251
+ async initializeRelationships(fallbackDbEngine) {
6252
+ if (!this.dbEngine && fallbackDbEngine) this.dbEngine = fallbackDbEngine;
5394
6253
  if (!this.dbEngine) {
5395
6254
  console.error(
5396
6255
  "[ModelRegistry] DB engine not available for initializing relationships. Ensure initializeAll has been called."
@@ -5542,7 +6401,16 @@ var init_ModelRegistry = __esm({
5542
6401
  return this.activeSessionModels || this.models;
5543
6402
  }
5544
6403
  validateSessionModels() {
5545
- const activeModels = this.getActiveModels();
6404
+ this.validateModels(this.getActiveModels());
6405
+ }
6406
+ /**
6407
+ * Every relationship of `activeModels` names a model that is also in it.
6408
+ *
6409
+ * Taken as an argument rather than read off the registry so a runtime
6410
+ * adoption can be validated against what the instance WOULD hold before it
6411
+ * holds it (#3615).
6412
+ */
6413
+ validateModels(activeModels) {
5546
6414
  if (!activeModels || activeModels.size === 0) {
5547
6415
  return;
5548
6416
  }
@@ -5994,6 +6862,14 @@ var init_IncludeResolver = __esm({
5994
6862
  function generateULID() {
5995
6863
  return (0, import_ulid2.ulid)();
5996
6864
  }
6865
+ function reindexFailure(modelConstructor, modelName, recordId, docId, error) {
6866
+ const message = error instanceof Error ? error.message : String(error);
6867
+ const failure = new Error(
6868
+ `[${modelConstructor.name}] Error re-indexing ${modelName}/${recordId} of document ${docId}: ${message}`
6869
+ );
6870
+ failure.cause = error;
6871
+ return failure;
6872
+ }
5997
6873
  var Y7, import_ulid2, FORMAT2_ROW_PAGE, UniqueConstraintViolationError, RecordNotFoundError, SCHEMA_ACCESSORS_KEY, BaseModel2;
5998
6874
  var init_BaseModel = __esm({
5999
6875
  "src/models/BaseModel.ts"() {
@@ -6866,19 +7742,31 @@ var init_BaseModel = __esm({
6866
7742
  if (fieldOptions?.default === void 0) return void 0;
6867
7743
  return typeof fieldOptions.default === "function" ? fieldOptions.default() : fieldOptions.default;
6868
7744
  }
6869
- getStringSetFromYjs(fieldName) {
6870
- const yjsData = this.getFromYjs(fieldName);
6871
- if (yjsData instanceof Y7.Map) {
6872
- return Array.from(yjsData.keys());
7745
+ /**
7746
+ * The members of a stringset as it is STORED, in stored order.
7747
+ *
7748
+ * Static and value-only so that the two readers of a stored stringset — the
7749
+ * instance's `getStringSetFromYjs`, which feeds the value a save builds its
7750
+ * unique key from, and `_readableUniqueOwner`, which rebuilds another
7751
+ * record's key from the map — cannot drift apart (#3252).
7752
+ */
7753
+ static _stringSetMembersFromStored(stored) {
7754
+ if (stored instanceof Y7.Map) {
7755
+ return Array.from(stored.keys());
6873
7756
  }
6874
- if (Array.isArray(yjsData)) {
6875
- return yjsData.filter((member) => typeof member === "string");
7757
+ if (Array.isArray(stored)) {
7758
+ return stored.filter((member) => typeof member === "string");
6876
7759
  }
6877
- if (yjsData && typeof yjsData === "object") {
6878
- return Object.keys(yjsData).filter((k) => Boolean(yjsData[k]));
7760
+ if (stored && typeof stored === "object") {
7761
+ return Object.keys(stored).filter((k) => Boolean(stored[k]));
6879
7762
  }
6880
7763
  return [];
6881
7764
  }
7765
+ getStringSetFromYjs(fieldName) {
7766
+ return this.constructor._stringSetMembersFromStored(
7767
+ this.getFromYjs(fieldName)
7768
+ );
7769
+ }
6882
7770
  getOrCreateStringSet(fieldName) {
6883
7771
  if (!this._stringSetFields.has(fieldName)) {
6884
7772
  const initialValues = this.getStringSetFromYjs(fieldName);
@@ -6907,6 +7795,30 @@ var init_BaseModel = __esm({
6907
7795
  `[${this.name}] initialize(yDoc, db) is no longer supported. Use initializeForDocument(yDoc, db, docId, permission)`
6908
7796
  );
6909
7797
  }
7798
+ /**
7799
+ * Record one observer's asynchronous write of `docId` on this class's ORM
7800
+ * instance (#3023), so a caller that needs the document queryable can wait
7801
+ * for it. The observers themselves track through the context they captured
7802
+ * at install time; this static is for callers that only hold a model class.
7803
+ */
7804
+ static trackDocumentIndexing(docId, work) {
7805
+ resolveOrmContext(this).trackDocumentIndexing(docId, work);
7806
+ }
7807
+ /**
7808
+ * Resolve once `docId`'s applied changes have reached this class's instance
7809
+ * of the query tables (#3023). See
7810
+ * {@link OrmContext.whenDocumentIndexed} — an `open()` that waited for the
7811
+ * initial sync waits for this too, otherwise it resolves on the sync signal
7812
+ * and the caller's first `query()` sees a partly-written table.
7813
+ */
7814
+ static async whenDocumentIndexed(docId) {
7815
+ const drained = await resolveOrmContext(this).whenDocumentIndexed(docId);
7816
+ if (!drained) {
7817
+ Logger.warn(
7818
+ `[BaseModel] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
7819
+ );
7820
+ }
7821
+ }
6910
7822
  /**
6911
7823
  * Initialize this model for a connected document.
6912
7824
  *
@@ -7253,7 +8165,7 @@ var init_BaseModel = __esm({
7253
8165
  return recordIdsToDiscard;
7254
8166
  };
7255
8167
  const observerContext = resolveOrmContext(this);
7256
- const documentYMapObserver = async (event) => {
8168
+ const indexDocumentYMapChange = async (event) => {
7257
8169
  if (observerContext.destroyed) return;
7258
8170
  Logger.verbose(
7259
8171
  `[${this.name}] Document YMap change detected for ${modelName}/${docId}:`,
@@ -7422,6 +8334,12 @@ var init_BaseModel = __esm({
7422
8334
  );
7423
8335
  observerContext.notifyListeners(modelName);
7424
8336
  };
8337
+ const documentYMapObserver = (event) => {
8338
+ observerContext.trackDocumentIndexing(
8339
+ docId,
8340
+ indexDocumentYMapChange(event)
8341
+ );
8342
+ };
7425
8343
  documentYMap.observe(documentYMapObserver);
7426
8344
  observerContext.registerObserverDisposer(
7427
8345
  docId,
@@ -7447,6 +8365,116 @@ var init_BaseModel = __esm({
7447
8365
  `[${this.name}] Model ${modelName} initialization complete for document ${docId}.`
7448
8366
  );
7449
8367
  }
8368
+ /**
8369
+ * Re-index ONE record of a document from its current Y.Map state (#3615).
8370
+ *
8371
+ * A legacy document's initial load walks the Y.Map before the permanent
8372
+ * observers are installed, so an update that lands in that window reaches
8373
+ * Yjs and misses the query tables. For a document connected long ago — which
8374
+ * is exactly what a model registered at runtime is initialized against —
8375
+ * that window is open on a live document. `registerModel` collects the
8376
+ * record ids touched while it runs and brings each of them here afterwards,
8377
+ * writing the same row the permanent per-record observer would have written.
8378
+ *
8379
+ * A record the window DELETED is deleted here for the same reason.
8380
+ *
8381
+ * A storage failure REJECTS rather than being logged and passed over, the
8382
+ * way the permanent observers' writes are: this write is part of what a
8383
+ * registration promises before it resolves, so swallowing it would resolve a
8384
+ * registration whose query tables are missing the very updates this call
8385
+ * exists to catch, and mark the pair complete so no retry would repair it.
8386
+ */
8387
+ static async reindexDocumentRecord(docId, recordId) {
8388
+ const modelConstructor = this;
8389
+ const schema = modelConstructor.getSchema?.();
8390
+ const modelName = schema?.options?.name;
8391
+ if (!modelName) return;
8392
+ const ctx = resolveOrmContext(modelConstructor);
8393
+ if (ctx.destroyed) return;
8394
+ const documentYMap = modelConstructor.documentYMaps.get(
8395
+ `${docId}_${modelName}`
8396
+ );
8397
+ if (!documentYMap) return;
8398
+ const engine = ctx.dbEngine ? await modelConstructor.queryEngineFor([docId], modelName) : null;
8399
+ if (!engine) return;
8400
+ const recordData = documentYMap.get(recordId);
8401
+ if (recordData === void 0 || recordData === null) {
8402
+ try {
8403
+ await engine.delete(modelName, recordId);
8404
+ } catch (error) {
8405
+ throw reindexFailure(
8406
+ modelConstructor,
8407
+ modelName,
8408
+ recordId,
8409
+ docId,
8410
+ error
8411
+ );
8412
+ }
8413
+ ctx.notifyListeners(modelName);
8414
+ return;
8415
+ }
8416
+ const entries = recordData instanceof Y7.Map ? Array.from(recordData.entries()) : Object.entries(recordData);
8417
+ const rowData = {};
8418
+ for (const [fieldKey, value] of entries) {
8419
+ const fieldOptions = schema.fields?.get(fieldKey);
8420
+ if (!fieldOptions || fieldOptions.type === "stringset") continue;
8421
+ if (value === void 0) continue;
8422
+ rowData[fieldKey] = isYjsComposite(value) ? normalizeYjsValueForStorage(value) : value;
8423
+ }
8424
+ if (!rowData.id) return;
8425
+ const permissionHint = modelConstructor.connectedDocuments.get(docId)?.permissionHint ?? "read-write";
8426
+ try {
8427
+ await engine.insert(modelName, {
8428
+ ...rowData,
8429
+ type: modelName,
8430
+ _meta_doc_id: docId,
8431
+ _meta_permission_hint: permissionHint
8432
+ });
8433
+ } catch (error) {
8434
+ throw reindexFailure(
8435
+ modelConstructor,
8436
+ modelName,
8437
+ recordId,
8438
+ docId,
8439
+ error
8440
+ );
8441
+ }
8442
+ ctx.notifyListeners(modelName);
8443
+ }
8444
+ /**
8445
+ * Withdraw ONE document's registration of this model (#3615).
8446
+ *
8447
+ * A `registerModel` that fails part-way through a document keeps the class
8448
+ * registered, because retrying is the recovery. This is what keeps that
8449
+ * half-finished pair from LOOKING finished in the meantime: without the
8450
+ * Y.Map mapping a save raises the same `YMap not found` an unregistered
8451
+ * model raises, instead of writing into a document whose observers were
8452
+ * never installed, and clearing the rows the failed load managed to write
8453
+ * keeps a query from answering out of a document nothing is watching. The
8454
+ * retry's `initializeForDocument` publishes both again.
8455
+ *
8456
+ * `connectedDocuments` is deliberately left alone: it is the CONTEXT's map,
8457
+ * shared by every model on the instance, so the document itself is still
8458
+ * connected for everything else.
8459
+ */
8460
+ static async withdrawDocumentRegistration(docId) {
8461
+ const modelConstructor = this;
8462
+ const schema = modelConstructor.getSchema?.();
8463
+ const modelName = schema?.options?.name;
8464
+ if (!modelName) return;
8465
+ modelConstructor.documentYMaps.delete(`${docId}_${modelName}`);
8466
+ const ctx = resolveOrmContext(modelConstructor);
8467
+ if (ctx.destroyed || !ctx.dbEngine) return;
8468
+ try {
8469
+ const engine = await modelConstructor.queryEngineFor([docId], modelName);
8470
+ await engine.deleteByDocumentId(modelName, docId);
8471
+ } catch (error) {
8472
+ Logger.warn(
8473
+ `[${modelConstructor.name}] Could not clear ${modelName} rows of document ${docId} while withdrawing an incomplete registration:`,
8474
+ error
8475
+ );
8476
+ }
8477
+ }
7450
8478
  // New method to clean up data for a disconnected document
7451
8479
  static async cleanupDocumentData(docId) {
7452
8480
  const modelConstructor = this;
@@ -7702,8 +8730,50 @@ var init_BaseModel = __esm({
7702
8730
  fields,
7703
8731
  values,
7704
8732
  modelName,
7705
- constraintName
8733
+ constraintName
8734
+ );
8735
+ }
8736
+ /**
8737
+ * The record that READABLY owns `uniqueKey` under `constraint` in one
8738
+ * document's model map, or `null`.
8739
+ *
8740
+ * The `_uniqueIdx_` map is a cache over the records, not the record store,
8741
+ * and the two can disagree (#3252): a replica can hold an index entry for a
8742
+ * record whose structs have not landed yet, or an entry a field change or
8743
+ * delete left behind. `save()` used to take the entry at its word, so the
8744
+ * app saw a `query` that returned no row for a value and a save refused for
8745
+ * that same value — the index knew a row nothing else could read.
8746
+ *
8747
+ * An entry counts only when its owner is readable: the record map exists
8748
+ * under that id in `modelYMap`, and the key built from the record's stored
8749
+ * fields is `uniqueKey`. Anything else is a stale entry, and the caller
8750
+ * treats it as an index miss — the engine fallback decides, exactly as it
8751
+ * does for a missing entry — so the write that follows lands and rewrites
8752
+ * the entry to the record that really holds the value.
8753
+ *
8754
+ * "The key built from the record's stored fields" has to mean the key that
8755
+ * record's own save built, so the stored value is read the way the save
8756
+ * read it: a stringset is stored as a nested Y.Map of its members but keyed
8757
+ * as the array `getCurrentJSState` puts in `dataToSave` (finding 3252-01).
8758
+ * Comparing the raw Y.Map would call every readable owner of a unique
8759
+ * stringset stale and let a duplicate straight through the constraint.
8760
+ */
8761
+ static _readableUniqueOwner(modelYMap, constraintMap, constraint, uniqueKey, modelName) {
8762
+ const ownerId = constraintMap.get(uniqueKey);
8763
+ if (!ownerId) return null;
8764
+ const recordYMap = modelYMap?.get(ownerId);
8765
+ if (!(recordYMap instanceof Y7.Map)) return null;
8766
+ const fields = this.getSchema?.()?.fields;
8767
+ const heldKey = this._buildKeyFromValues(
8768
+ constraint.fields,
8769
+ constraint.fields.map((field) => {
8770
+ const stored = recordYMap.get(field);
8771
+ return fields?.get(field)?.type === "stringset" ? this._stringSetMembersFromStored(stored) : stored;
8772
+ }),
8773
+ modelName,
8774
+ constraint.name
7706
8775
  );
8776
+ return heldKey === uniqueKey ? ownerId : null;
7707
8777
  }
7708
8778
  /**
7709
8779
  * The connected format-2 document that holds this instance's record, for an
@@ -7902,7 +8972,14 @@ var init_BaseModel = __esm({
7902
8972
  const constraintMap = yDoc.getMap(
7903
8973
  uniqueIndexMapName(modelName, constraint.name)
7904
8974
  );
7905
- if (!constraintMap.get(newUniqueKey)) {
8975
+ const readableOwner = modelConstructor._readableUniqueOwner(
8976
+ targetYMap,
8977
+ constraintMap,
8978
+ constraint,
8979
+ newUniqueKey,
8980
+ modelName
8981
+ );
8982
+ if (!readableOwner) {
7906
8983
  const constraintKeyValues = constraint.fields.map(
7907
8984
  (field) => dataToSave[field]
7908
8985
  );
@@ -7920,7 +8997,13 @@ var init_BaseModel = __esm({
7920
8997
  if (upsertOnConstraint && upsertOnKey) {
7921
8998
  const constraintMapName = `_uniqueIdx_${modelName}_${upsertOnConstraint.name}`;
7922
8999
  const constraintMap = yDoc.getMap(constraintMapName);
7923
- const existingId = constraintMap.get(upsertOnKey);
9000
+ const existingId = modelConstructor._readableUniqueOwner(
9001
+ targetYMap ?? void 0,
9002
+ constraintMap,
9003
+ upsertOnConstraint,
9004
+ upsertOnKey,
9005
+ modelName
9006
+ );
7924
9007
  if (existingId) {
7925
9008
  if (this._constructorProvidedId && this.id !== existingId) {
7926
9009
  throw new Error(
@@ -8020,7 +9103,13 @@ var init_BaseModel = __esm({
8020
9103
  50
8021
9104
  )}'`
8022
9105
  );
8023
- const existingRecordIdWithNewKey = constraintMap.get(newUniqueKey);
9106
+ const existingRecordIdWithNewKey = modelConstructor._readableUniqueOwner(
9107
+ targetYMap ?? void 0,
9108
+ constraintMap,
9109
+ constraint,
9110
+ newUniqueKey,
9111
+ modelName
9112
+ );
8024
9113
  Logger.verbose(
8025
9114
  `[${modelName}] Save Check (Item '${this.id}'): Found Existing ID: '${existingRecordIdWithNewKey}' for Key '${newUniqueKey.substring(
8026
9115
  0,
@@ -10158,7 +11247,7 @@ var init_BaseModel = __esm({
10158
11247
  );
10159
11248
  modelConstructor.attachStringSetObserversToRecord(recordYMap, schema, docId);
10160
11249
  const observerContext = resolveOrmContext(modelConstructor);
10161
- const recordObserver = async (event) => {
11250
+ const indexNestedYMapChange = async (event) => {
10162
11251
  if (observerContext.destroyed) return;
10163
11252
  Logger.verbose(
10164
11253
  `[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
@@ -10234,6 +11323,12 @@ var init_BaseModel = __esm({
10234
11323
  }
10235
11324
  observerContext.notifyListeners(modelName);
10236
11325
  };
11326
+ const recordObserver = (event) => {
11327
+ observerContext.trackDocumentIndexing(
11328
+ docId,
11329
+ indexNestedYMapChange(event)
11330
+ );
11331
+ };
10237
11332
  recordYMap.observe(recordObserver);
10238
11333
  observerContext.registerObserverDisposer(
10239
11334
  docId,
@@ -10265,6 +11360,7 @@ __export(browser_exports, {
10265
11360
  Format2RecordStoreAsync: () => Format2RecordStoreAsync,
10266
11361
  Format2RowCache: () => Format2RowCache,
10267
11362
  Format2StorageCapabilityError: () => Format2StorageCapabilityError,
11363
+ INGEST_CHUNK_LIMITS: () => INGEST_CHUNK_LIMITS,
10268
11364
  InProcessFormat2StoreHost: () => InProcessFormat2StoreHost,
10269
11365
  LogLevel: () => LogLevel,
10270
11366
  Logger: () => Logger,
@@ -10308,6 +11404,8 @@ __export(browser_exports, {
10308
11404
  encodeOverlayMutation: () => encodeOverlayMutation,
10309
11405
  estimateMaterializedBytes: () => estimateMaterializedBytes,
10310
11406
  features: () => features,
11407
+ findReservedFieldNameInToml: () => findReservedFieldNameInToml,
11408
+ foldFormat2Overlay: () => foldFormat2Overlay,
10311
11409
  followEpoch: () => followEpoch,
10312
11410
  format2ClientDDL: () => format2ClientDDL,
10313
11411
  format2EpochDocName: () => format2EpochDocName,
@@ -10330,6 +11428,7 @@ __export(browser_exports, {
10330
11428
  loadSchemaFromTomlString: () => loadSchemaFromTomlString,
10331
11429
  materializeOverlayRecord: () => materializeOverlayRecord,
10332
11430
  observeFormat2Overlay: () => observeFormat2Overlay,
11431
+ offlineReplayRecordKey: () => offlineReplayRecordKey,
10333
11432
  offlineWindowStatus: () => offlineWindowStatus,
10334
11433
  overlayFieldKey: () => overlayFieldKey,
10335
11434
  overlayMarkerKey: () => overlayMarkerKey,
@@ -10338,6 +11437,7 @@ __export(browser_exports, {
10338
11437
  parseSnapshotChunkPath: () => parseSnapshotChunkPath,
10339
11438
  planColdStart: () => planColdStart,
10340
11439
  planFastForward: () => planFastForward,
11440
+ planRangeReplacement: () => planRangeReplacement,
10341
11441
  planSnapshotHydration: () => planSnapshotHydration,
10342
11442
  probeStorageCapability: () => probeStorageCapability,
10343
11443
  projectOverlayEntry: () => projectOverlayEntry,
@@ -10353,6 +11453,8 @@ __export(browser_exports, {
10353
11453
  schemaToToml: () => schemaToToml,
10354
11454
  serveFormat2StoreHost: () => serveFormat2StoreHost,
10355
11455
  snapshotChunkPath: () => snapshotChunkPath,
11456
+ statePendingOps: () => statePendingOps,
11457
+ suppressSupersededKeys: () => suppressSupersededKeys,
10356
11458
  syncInferredMeta: () => syncInferredMeta,
10357
11459
  syncModelMeta: () => syncModelMeta,
10358
11460
  validateSnapshotManifest: () => validateSnapshotManifest,
@@ -10966,387 +12068,135 @@ var SqljsEngine = class _SqljsEngine extends SqliteQueryEngineBase {
10966
12068
  throw error;
10967
12069
  }
10968
12070
  }
10969
- /** The open connection, once the wasm has loaded. */
10970
- async connection() {
10971
- await this.readyPromise;
10972
- if (!this.db || !this.sync) throw new Error("SQL.js not initialized");
10973
- return this.sync;
10974
- }
10975
- async withTransaction(callback) {
10976
- await this.readyPromise;
10977
- if (!this.db)
10978
- throw new Error("SQL.js Database instance not available for transaction");
10979
- let transactionStartedHere = false;
10980
- await this.mutex.runExclusive(async () => {
10981
- if (this.numOpenTransactions === 0) {
10982
- if (!this.db) throw new Error("SQL.js DB not available for BEGIN");
10983
- this.db.run("BEGIN TRANSACTION");
10984
- transactionStartedHere = true;
10985
- }
10986
- this.numOpenTransactions++;
10987
- });
10988
- const transactionalOps = new TransactionalSQLJSOperations(this.db, this);
10989
- try {
10990
- const result = await callback(transactionalOps);
10991
- await this.mutex.runExclusive(async () => {
10992
- this.numOpenTransactions--;
10993
- if (this.numOpenTransactions === 0) {
10994
- if (!this.db) throw new Error("SQL.js DB not available for COMMIT");
10995
- this.db.run("COMMIT");
10996
- }
10997
- });
10998
- return result;
10999
- } catch (error) {
11000
- Logger.error("[SqljsEngine] Error in transaction, rolling back:", error);
11001
- await this.mutex.runExclusive(async () => {
11002
- if (this.db && this.numOpenTransactions > 0 && transactionStartedHere) {
11003
- try {
11004
- if (!this.db)
11005
- throw new Error("SQL.js DB not available for ROLLBACK");
11006
- this.db.run("ROLLBACK");
11007
- } catch (rollbackError) {
11008
- Logger.error("[SqljsEngine] Error during ROLLBACK:", rollbackError);
11009
- }
11010
- }
11011
- this.numOpenTransactions = 0;
11012
- });
11013
- throw error;
11014
- }
11015
- }
11016
- /**
11017
- * Synchronous executor for the format-2 record store (#2816). SQL.js has no
11018
- * savepoints exposed here, so nesting reuses the open transaction rather
11019
- * than issuing a second BEGIN.
11020
- */
11021
- /**
11022
- * SQL.js is an in-memory database (#2816): `new SQL.Database()` and nothing
11023
- * behind it. It can host a large document's merged view for the life of a
11024
- * page, and cannot BE one — a reload would take the document and every write
11025
- * the server has not acknowledged with it. Until the worker-hosted OPFS
11026
- * engine lands, saying so is what turns that into a typed refusal before a
11027
- * cold load starts writing, instead of a document that quietly evaporates.
11028
- */
11029
- format2StorageIsDurable() {
11030
- return false;
11031
- }
11032
- createFormat2Sql() {
11033
- if (!this.db) {
11034
- throw new Error(
11035
- "[SqljsEngine] SQL.js not initialized; await ensureReady() before creating the format-2 executor"
11036
- );
11037
- }
11038
- const db = this.db;
11039
- let depth = 0;
11040
- return {
11041
- exec(query, ...bindings) {
11042
- const stmt = db.prepare(query);
11043
- try {
11044
- if (bindings.length > 0) stmt.bind(bindings);
11045
- const rows = [];
11046
- while (stmt.step()) rows.push(stmt.getAsObject());
11047
- return rows;
11048
- } finally {
11049
- stmt.free();
11050
- }
11051
- },
11052
- transaction(fn) {
11053
- if (depth > 0) return fn();
11054
- depth++;
11055
- db.run("BEGIN TRANSACTION");
11056
- try {
11057
- const result = fn();
11058
- db.run("COMMIT");
11059
- return result;
11060
- } catch (error) {
11061
- db.run("ROLLBACK");
11062
- throw error;
11063
- } finally {
11064
- depth--;
11065
- }
11066
- }
11067
- };
11068
- }
11069
- async close() {
11070
- await this.readyPromise;
11071
- if (this.db) {
11072
- this.db.close();
11073
- this.db = null;
11074
- this.sync = null;
11075
- _SqljsEngine.SQL = null;
11076
- }
11077
- }
11078
- async getTableSchema(_tableName) {
11079
- return void 0;
11080
- }
11081
- async destroy() {
11082
- if (this.db) {
11083
- this.db.close();
11084
- Logger.info("[SqljsEngine] Database closed.");
11085
- }
11086
- }
11087
- getLastErrorMessage() {
11088
- return void 0;
11089
- }
11090
- };
11091
-
11092
- // src/engines/opfs/OpfsEngine.ts
11093
- init_logger();
11094
- init_sql();
11095
-
11096
- // src/utils/snapshotChunks.ts
11097
- var FORMAT2_MANIFEST_VERSION = 3;
11098
- var FORMAT2_MIN_MANIFEST_VERSION = 2;
11099
- var SnapshotChunkIntegrityError = class extends Error {
11100
- constructor(key, reason) {
11101
- super(`Snapshot chunk ${key} does not match its manifest entry: ${reason}`);
11102
- this.key = key;
11103
- this.reason = reason;
11104
- this.name = "SnapshotChunkIntegrityError";
11105
- }
11106
- };
11107
- var SnapshotManifestInvalidError = class extends Error {
11108
- code = "SNAPSHOT_MANIFEST_INVALID";
11109
- constructor(reason) {
11110
- super(`Snapshot manifest is not valid: ${reason}`);
11111
- this.name = "SnapshotManifestInvalidError";
11112
- }
11113
- };
11114
- var SnapshotManifestUnsupportedError = class extends Error {
11115
- constructor(manifestVersion, supportedVersion = FORMAT2_MANIFEST_VERSION) {
11116
- super(
11117
- `Snapshot manifest is version ${manifestVersion}; this client reads up to version ${supportedVersion}. Upgrade the client library.`
11118
- );
11119
- this.manifestVersion = manifestVersion;
11120
- this.supportedVersion = supportedVersion;
11121
- this.name = "SnapshotManifestUnsupportedError";
11122
- }
11123
- code = "SNAPSHOT_MANIFEST_UNSUPPORTED";
11124
- };
11125
- var CHUNK_PATH_OWN = /^([A-Za-z][A-Za-z0-9_]*)\/(\d+)$/;
11126
- var CHUNK_PATH_SOURCED = /^(\d+)-([A-Za-z0-9_-]{1,64})\/([A-Za-z][A-Za-z0-9_]*)\/(\d+)$/;
11127
- function parseSnapshotChunkPath(path) {
11128
- if (!path || typeof path !== "string") return null;
11129
- const own = CHUNK_PATH_OWN.exec(path);
11130
- if (own) return { model: own[1], index: Number(own[2]) };
11131
- const sourced = CHUNK_PATH_SOURCED.exec(path);
11132
- if (!sourced) return null;
11133
- return {
11134
- model: sourced[3],
11135
- index: Number(sourced[4]),
11136
- source: { epoch: Number(sourced[1]), buildId: sourced[2] }
11137
- };
11138
- }
11139
- function resolveChunkOrdinal(entry, index) {
11140
- return typeof entry.ordinal === "number" ? entry.ordinal : index;
11141
- }
11142
- function collationRank(unit) {
11143
- if (unit >= 57344) return unit - 2048;
11144
- if (unit >= 55296) return unit + 8192;
11145
- return unit;
11146
- }
11147
- function compareRecordIds(left, right) {
11148
- if (left === right) return 0;
11149
- const shared = Math.min(left.length, right.length);
11150
- for (let index = 0; index < shared; index++) {
11151
- const a = left.charCodeAt(index);
11152
- const b = right.charCodeAt(index);
11153
- if (a === b) continue;
11154
- return collationRank(a) < collationRank(b) ? -1 : 1;
11155
- }
11156
- return left.length < right.length ? -1 : left.length > right.length ? 1 : 0;
11157
- }
11158
- function validateSnapshotManifest(manifest) {
11159
- if (!manifest || typeof manifest !== "object") {
11160
- throw new SnapshotManifestInvalidError("it is not an object");
11161
- }
11162
- const version = manifest.version;
11163
- if (!Number.isInteger(version)) {
11164
- throw new SnapshotManifestInvalidError(`version ${String(version)} is not an integer`);
11165
- }
11166
- if (version > FORMAT2_MANIFEST_VERSION) {
11167
- throw new SnapshotManifestUnsupportedError(version);
11168
- }
11169
- if (version < FORMAT2_MIN_MANIFEST_VERSION) {
11170
- throw new SnapshotManifestInvalidError(
11171
- `version ${version} is older than the large-document format (${FORMAT2_MIN_MANIFEST_VERSION})`
11172
- );
11173
- }
11174
- const chunks = manifest.chunks;
11175
- if (!Array.isArray(chunks)) {
11176
- throw new SnapshotManifestInvalidError("its chunk list is not an array");
11177
- }
11178
- const seenOrdinals = /* @__PURE__ */ new Set();
11179
- const lastRangeEndPerModel = /* @__PURE__ */ new Map();
11180
- let rows = 0;
11181
- let bytes = 0;
11182
- for (let index = 0; index < chunks.length; index++) {
11183
- const entry = chunks[index];
11184
- const where = `chunk ${index} (${entry?.model ?? "?"})`;
11185
- if (!entry || typeof entry !== "object") {
11186
- throw new SnapshotManifestInvalidError(`${where} is not an object`);
11187
- }
11188
- if (entry.ordinal !== void 0 && !Number.isSafeInteger(entry.ordinal)) {
11189
- throw new SnapshotManifestInvalidError(
11190
- `${where} has ordinal ${String(entry.ordinal)}, which is not an integer`
11191
- );
11192
- }
11193
- const ordinal = resolveChunkOrdinal(entry, index);
11194
- if (!Number.isSafeInteger(ordinal) || ordinal < 0) {
11195
- throw new SnapshotManifestInvalidError(
11196
- `${where} resolves to ordinal ${String(ordinal)}, which is not a non-negative integer`
11197
- );
11198
- }
11199
- if (seenOrdinals.has(ordinal)) {
11200
- throw new SnapshotManifestInvalidError(
11201
- `${where} shares ordinal ${ordinal} with an earlier chunk`
11202
- );
11203
- }
11204
- seenOrdinals.add(ordinal);
11205
- if (entry.path !== void 0 && !parseSnapshotChunkPath(entry.path)) {
11206
- throw new SnapshotManifestInvalidError(
11207
- `${where} has a path that is not addressable: ${String(entry.path)}`
11208
- );
11209
- }
11210
- if (entry.rows > 0 && compareRecordIds(entry.firstId, entry.lastId) > 0) {
11211
- throw new SnapshotManifestInvalidError(
11212
- `${where} spans ${entry.firstId}..${entry.lastId}, which runs backwards`
11213
- );
11214
- }
11215
- if (entry.rows > 0) {
11216
- const previousEnd = lastRangeEndPerModel.get(entry.model);
11217
- if (previousEnd !== void 0 && compareRecordIds(entry.firstId, previousEnd) <= 0) {
11218
- throw new SnapshotManifestInvalidError(
11219
- `${where} starts at ${entry.firstId}, at or before ${previousEnd} where the previous chunk of ${entry.model} ended`
11220
- );
11221
- }
11222
- lastRangeEndPerModel.set(entry.model, entry.lastId);
11223
- }
11224
- rows += Number(entry.rows) || 0;
11225
- bytes += Number(entry.bytes) || 0;
11226
- }
11227
- const totals = manifest.totals;
11228
- if (!totals || totals.rows !== rows || totals.bytes !== bytes) {
11229
- throw new SnapshotManifestInvalidError(
11230
- `its totals say ${totals?.rows}/${totals?.bytes} rows/bytes but its chunks add up to ${rows}/${bytes}`
11231
- );
12071
+ /** The open connection, once the wasm has loaded. */
12072
+ async connection() {
12073
+ await this.readyPromise;
12074
+ if (!this.db || !this.sync) throw new Error("SQL.js not initialized");
12075
+ return this.sync;
11232
12076
  }
11233
- }
11234
- function snapshotStringSetFields(manifest, model) {
11235
- const declared = manifest.schema?.[model]?.stringSetFields;
11236
- return new Set(Array.isArray(declared) ? declared.map(String) : []);
11237
- }
11238
- function snapshotRowAsOverlayEntry(id, data, stringSetFields) {
11239
- const fields = {};
11240
- const stringSets = {};
11241
- for (const [field, value] of Object.entries(data)) {
11242
- if (field === "id") continue;
11243
- if (stringSetFields.has(field) && Array.isArray(value)) {
11244
- const members = {};
11245
- for (const member of value) members[String(member)] = true;
11246
- stringSets[field] = members;
11247
- continue;
12077
+ async withTransaction(callback) {
12078
+ await this.readyPromise;
12079
+ if (!this.db)
12080
+ throw new Error("SQL.js Database instance not available for transaction");
12081
+ let transactionStartedHere = false;
12082
+ await this.mutex.runExclusive(async () => {
12083
+ if (this.numOpenTransactions === 0) {
12084
+ if (!this.db) throw new Error("SQL.js DB not available for BEGIN");
12085
+ this.db.run("BEGIN TRANSACTION");
12086
+ transactionStartedHere = true;
12087
+ }
12088
+ this.numOpenTransactions++;
12089
+ });
12090
+ const transactionalOps = new TransactionalSQLJSOperations(this.db, this);
12091
+ try {
12092
+ const result = await callback(transactionalOps);
12093
+ await this.mutex.runExclusive(async () => {
12094
+ this.numOpenTransactions--;
12095
+ if (this.numOpenTransactions === 0) {
12096
+ if (!this.db) throw new Error("SQL.js DB not available for COMMIT");
12097
+ this.db.run("COMMIT");
12098
+ }
12099
+ });
12100
+ return result;
12101
+ } catch (error) {
12102
+ Logger.error("[SqljsEngine] Error in transaction, rolling back:", error);
12103
+ await this.mutex.runExclusive(async () => {
12104
+ if (this.db && this.numOpenTransactions > 0 && transactionStartedHere) {
12105
+ try {
12106
+ if (!this.db)
12107
+ throw new Error("SQL.js DB not available for ROLLBACK");
12108
+ this.db.run("ROLLBACK");
12109
+ } catch (rollbackError) {
12110
+ Logger.error("[SqljsEngine] Error during ROLLBACK:", rollbackError);
12111
+ }
12112
+ }
12113
+ this.numOpenTransactions = 0;
12114
+ });
12115
+ throw error;
11248
12116
  }
11249
- fields[field] = value;
11250
- }
11251
- return { id, fields, stringSets, replace: true, deleted: false };
11252
- }
11253
- var encoder = new TextEncoder();
11254
- var decoder = new TextDecoder();
11255
- async function gunzip(bytes) {
11256
- return await collect(
11257
- new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"))
11258
- );
11259
- }
11260
- async function collect(stream) {
11261
- const parts = [];
11262
- let total = 0;
11263
- const reader = stream.getReader();
11264
- for (; ; ) {
11265
- const { done, value } = await reader.read();
11266
- if (done) break;
11267
- parts.push(value);
11268
- total += value.byteLength;
11269
12117
  }
11270
- const out = new Uint8Array(total);
11271
- let at = 0;
11272
- for (const part of parts) {
11273
- out.set(part, at);
11274
- at += part.byteLength;
12118
+ /**
12119
+ * Synchronous executor for the format-2 record store (#2816). SQL.js has no
12120
+ * savepoints exposed here, so nesting reuses the open transaction rather
12121
+ * than issuing a second BEGIN.
12122
+ */
12123
+ /**
12124
+ * SQL.js is an in-memory database (#2816): `new SQL.Database()` and nothing
12125
+ * behind it. It can host a large document's merged view for the life of a
12126
+ * page, and cannot BE one — a reload would take the document and every write
12127
+ * the server has not acknowledged with it. Until the worker-hosted OPFS
12128
+ * engine lands, saying so is what turns that into a typed refusal before a
12129
+ * cold load starts writing, instead of a document that quietly evaporates.
12130
+ */
12131
+ format2StorageIsDurable() {
12132
+ return false;
11275
12133
  }
11276
- return out;
11277
- }
11278
- async function sha256Hex(bytes) {
11279
- const digest = await crypto.subtle.digest("SHA-256", bytes);
11280
- return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
11281
- }
11282
- async function readSnapshotChunk(body, descriptor) {
11283
- const entry = "sha256" in descriptor ? descriptor : null;
11284
- if (entry) {
11285
- if (body.byteLength !== entry.bytes) {
11286
- throw new SnapshotChunkIntegrityError(
11287
- entry.key,
11288
- `expected ${entry.bytes} bytes, got ${body.byteLength}`
11289
- );
11290
- }
11291
- const digest = await sha256Hex(body);
11292
- if (digest !== entry.sha256) {
11293
- throw new SnapshotChunkIntegrityError(
11294
- entry.key,
11295
- `expected sha256 ${entry.sha256}, got ${digest}`
12134
+ createFormat2Sql() {
12135
+ if (!this.db) {
12136
+ throw new Error(
12137
+ "[SqljsEngine] SQL.js not initialized; await ensureReady() before creating the format-2 executor"
11296
12138
  );
11297
12139
  }
12140
+ const db = this.db;
12141
+ let depth = 0;
12142
+ return {
12143
+ exec(query, ...bindings) {
12144
+ const stmt = db.prepare(query);
12145
+ try {
12146
+ if (bindings.length > 0) stmt.bind(bindings);
12147
+ const rows = [];
12148
+ while (stmt.step()) rows.push(stmt.getAsObject());
12149
+ return rows;
12150
+ } finally {
12151
+ stmt.free();
12152
+ }
12153
+ },
12154
+ transaction(fn) {
12155
+ if (depth > 0) return fn();
12156
+ depth++;
12157
+ db.run("BEGIN TRANSACTION");
12158
+ try {
12159
+ const result = fn();
12160
+ db.run("COMMIT");
12161
+ return result;
12162
+ } catch (error) {
12163
+ db.run("ROLLBACK");
12164
+ throw error;
12165
+ } finally {
12166
+ depth--;
12167
+ }
12168
+ }
12169
+ };
11298
12170
  }
11299
- const text = decoder.decode(await gunzip(body));
11300
- const rows = [];
11301
- for (const line of text.split("\n")) {
11302
- if (line.length === 0) continue;
11303
- const tab = line.indexOf(" ");
11304
- if (tab < 0) {
11305
- throw new SnapshotChunkIntegrityError(
11306
- entry?.key ?? descriptor.model,
11307
- "a line carries no id/data separator"
11308
- );
12171
+ async close() {
12172
+ await this.readyPromise;
12173
+ if (this.db) {
12174
+ this.db.close();
12175
+ this.db = null;
12176
+ this.sync = null;
12177
+ _SqljsEngine.SQL = null;
11309
12178
  }
11310
- rows.push({
11311
- _type: descriptor.model,
11312
- _id: line.slice(0, tab),
11313
- _data: line.slice(tab + 1)
11314
- });
11315
12179
  }
11316
- if (entry && rows.length !== entry.rows) {
11317
- throw new SnapshotChunkIntegrityError(
11318
- entry.key,
11319
- `expected ${entry.rows} rows, got ${rows.length}`
11320
- );
12180
+ async getTableSchema(_tableName) {
12181
+ return void 0;
11321
12182
  }
11322
- if (entry && rows.length > 0) {
11323
- const seen = /* @__PURE__ */ new Set();
11324
- for (const row of rows) {
11325
- if (seen.has(row._id)) {
11326
- throw new SnapshotChunkIntegrityError(
11327
- entry.key,
11328
- `duplicate id ${row._id}`
11329
- );
11330
- }
11331
- seen.add(row._id);
11332
- }
11333
- if (rows[0]._id !== entry.firstId) {
11334
- throw new SnapshotChunkIntegrityError(
11335
- entry.key,
11336
- `expected it to begin at ${entry.firstId}, got ${rows[0]._id}`
11337
- );
11338
- }
11339
- if (rows[rows.length - 1]._id !== entry.lastId) {
11340
- throw new SnapshotChunkIntegrityError(
11341
- entry.key,
11342
- `expected it to end at ${entry.lastId}, got ${rows[rows.length - 1]._id}`
11343
- );
12183
+ async destroy() {
12184
+ if (this.db) {
12185
+ this.db.close();
12186
+ Logger.info("[SqljsEngine] Database closed.");
11344
12187
  }
11345
12188
  }
11346
- return rows;
11347
- }
12189
+ getLastErrorMessage() {
12190
+ return void 0;
12191
+ }
12192
+ };
12193
+
12194
+ // src/engines/opfs/OpfsEngine.ts
12195
+ init_logger();
12196
+ init_sql();
11348
12197
 
11349
12198
  // src/models/format2StorageCapability.ts
12199
+ init_snapshotChunks();
11350
12200
  var SNAPSHOT_STORAGE_EXPANSION = 2;
11351
12201
  var SNAPSHOT_COMPRESSED_EXPANSION = 8;
11352
12202
  function estimateMaterializedBytes(chunk) {
@@ -13558,10 +14408,19 @@ function sameOverlayValue(a, b) {
13558
14408
  if (a === null || a === void 0) return b === null || b === void 0;
13559
14409
  return JSON.stringify(a) === JSON.stringify(b);
13560
14410
  }
13561
- function restorePendingOverlay(doc, store, verdicts) {
13562
- const result = { restored: [], unreproducible: [] };
14411
+ function restorePendingOverlay(doc, store, verdicts, options = {}) {
14412
+ const result = {
14413
+ restored: [],
14414
+ unreproducible: [],
14415
+ deferred: []
14416
+ };
14417
+ const defer = options.deferAtOrBelowEpoch ?? 0;
13563
14418
  const missing = [];
13564
14419
  for (const op of store.pendingOps()) {
14420
+ if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer) {
14421
+ result.deferred.push(op.seq);
14422
+ continue;
14423
+ }
13565
14424
  const verdict = verdicts?.get(op.seq);
13566
14425
  if (verdict === "carried" || verdict === "superseded") continue;
13567
14426
  if (verdict === void 0 && overlayCarries(doc, op)) continue;
@@ -13586,8 +14445,27 @@ function restorePendingOverlay(doc, store, verdicts) {
13586
14445
  }, FORMAT2_LOCAL_WRITE_ORIGIN);
13587
14446
  return result;
13588
14447
  }
14448
+ function statePendingOps(doc, ops, options = {}) {
14449
+ const suppressed = new Set(
14450
+ (options.suppressDeletes ?? []).map(
14451
+ (entry) => `${entry.model} ${entry.recordId}`
14452
+ )
14453
+ );
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
+ );
14457
+ if (stated.length === 0) return 0;
14458
+ doc.transact(() => {
14459
+ for (const op of stated) {
14460
+ applyOverlayMutation(doc.getMap(op.model), op.mutation);
14461
+ }
14462
+ });
14463
+ return stated.length;
14464
+ }
13589
14465
 
13590
14466
  // src/models/format2SnapshotLoader.ts
14467
+ init_snapshotChunks();
14468
+ var PRUNE_PAGE_ROWS = 500;
13591
14469
  var SnapshotLoadIncompleteError = class extends Error {
13592
14470
  constructor(buildId, missing, passes) {
13593
14471
  super(
@@ -13629,6 +14507,7 @@ async function loadFormat2Snapshot(options) {
13629
14507
  const { store, manifest } = options;
13630
14508
  const retries = options.retries ?? 1;
13631
14509
  validateSnapshotManifest(manifest);
14510
+ await store.beginBase(manifest.buildId, manifest.epoch);
13632
14511
  const already = new Set(await store.completedChunks(manifest.buildId));
13633
14512
  const ordinals = new Map(
13634
14513
  manifest.chunks.map((chunk, index) => [chunk, resolveChunkOrdinal(chunk, index)])
@@ -13636,7 +14515,20 @@ async function loadFormat2Snapshot(options) {
13636
14515
  await store.setHydrationScope(options.models ? [...options.models] : null);
13637
14516
  const cap = options.models;
13638
14517
  const wanted = (model) => cap === void 0 || cap.includes(model);
13639
- const selected = manifest.chunks.filter((chunk) => wanted(chunk.model));
14518
+ const plan = options.plan;
14519
+ const coverages = new Map(
14520
+ plan?.fetch.map((entry) => [entry.chunk, entry.coverage]) ?? []
14521
+ );
14522
+ const keptChunks = new Set(plan?.keep ?? []);
14523
+ const selected = (plan ? [...plan.fetch.map((entry) => entry.chunk), ...plan.keep].sort(
14524
+ (a, b) => manifest.chunks.indexOf(a) - manifest.chunks.indexOf(b)
14525
+ ) : manifest.chunks).filter((chunk) => wanted(chunk.model));
14526
+ const announcedTouched = /* @__PURE__ */ new Set();
14527
+ const announceTouched = async (model) => {
14528
+ if (announcedTouched.has(model)) return;
14529
+ announcedTouched.add(model);
14530
+ await options.onModelTouched?.(model);
14531
+ };
13640
14532
  const totalChunks = selected.length;
13641
14533
  const totalRows = selected.reduce((total, chunk) => total + chunk.rows, 0);
13642
14534
  const models = [];
@@ -13657,6 +14549,14 @@ async function loadFormat2Snapshot(options) {
13657
14549
  );
13658
14550
  if (!models.includes(chunk.model)) models.push(chunk.model);
13659
14551
  }
14552
+ for (const model of plan?.pruneModels ?? []) {
14553
+ await announceTouched(model);
14554
+ for (; ; ) {
14555
+ const page = await store.deleteRange(model, null, null, PRUNE_PAGE_ROWS);
14556
+ if (page.done) break;
14557
+ }
14558
+ await options.onModelReady?.(model);
14559
+ }
13660
14560
  const applyOne = async (chunk, outstanding) => {
13661
14561
  let decoded = null;
13662
14562
  let lastError = null;
@@ -13673,14 +14573,27 @@ async function loadFormat2Snapshot(options) {
13673
14573
  }
13674
14574
  if (!decoded) throw new SnapshotLoadIntegrityError(chunk, lastError);
13675
14575
  const stringSetFields = snapshotStringSetFields(manifest, chunk.model);
13676
- await store.applyChunk(
13677
- chunk.model,
13678
- decoded.map(
13679
- (row) => rowAsEntry(row._id, JSON.parse(row._data), stringSetFields)
13680
- ),
13681
- manifest.buildId,
13682
- ordinals.get(chunk)
14576
+ const coverage = coverages.get(chunk);
14577
+ const entries = decoded.map(
14578
+ (row) => rowAsEntry(row._id, JSON.parse(row._data), stringSetFields)
13683
14579
  );
14580
+ if (coverage) {
14581
+ await announceTouched(chunk.model);
14582
+ await store.replaceChunk(
14583
+ chunk.model,
14584
+ coverage,
14585
+ entries,
14586
+ manifest.buildId,
14587
+ ordinals.get(chunk)
14588
+ );
14589
+ } else {
14590
+ await store.applyChunk(
14591
+ chunk.model,
14592
+ entries,
14593
+ manifest.buildId,
14594
+ ordinals.get(chunk)
14595
+ );
14596
+ }
13684
14597
  chunks++;
13685
14598
  rows += decoded.length;
13686
14599
  options.onProgress?.({
@@ -13692,6 +14605,7 @@ async function loadFormat2Snapshot(options) {
13692
14605
  });
13693
14606
  if (outstanding === 0) await options.onModelReady?.(chunk.model);
13694
14607
  };
14608
+ let reused = 0;
13695
14609
  for (const chunk of selected) {
13696
14610
  const outstanding = (remainingPerModel.get(chunk.model) ?? 1) - 1;
13697
14611
  remainingPerModel.set(chunk.model, outstanding);
@@ -13702,6 +14616,14 @@ async function loadFormat2Snapshot(options) {
13702
14616
  if (outstanding === 0) await options.onModelReady?.(chunk.model);
13703
14617
  continue;
13704
14618
  }
14619
+ if (keptChunks.has(chunk)) {
14620
+ await store.applyChunk(chunk.model, [], manifest.buildId, ordinals.get(chunk));
14621
+ reused++;
14622
+ chunks++;
14623
+ rows += chunk.rows;
14624
+ if (outstanding === 0) await options.onModelReady?.(chunk.model);
14625
+ continue;
14626
+ }
13705
14627
  await applyOne(chunk, outstanding);
13706
14628
  }
13707
14629
  const repaired = /* @__PURE__ */ new Set();
@@ -13723,6 +14645,7 @@ async function loadFormat2Snapshot(options) {
13723
14645
  repaired.add(chunk.model);
13724
14646
  }
13725
14647
  }
14648
+ await store.completeBase(manifest.buildId);
13726
14649
  return {
13727
14650
  rows,
13728
14651
  chunks,
@@ -13730,7 +14653,8 @@ async function loadFormat2Snapshot(options) {
13730
14653
  refetched,
13731
14654
  models,
13732
14655
  skipped,
13733
- repaired: [...repaired]
14656
+ repaired: [...repaired],
14657
+ reused
13734
14658
  };
13735
14659
  }
13736
14660
 
@@ -13848,7 +14772,20 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
13848
14772
  await store.whenBatchesSettled();
13849
14773
  if (options.restorePending !== false) {
13850
14774
  const verdicts = await this.adoptOrphanedWrites(docId, yDoc, store);
13851
- const restored = restorePendingOverlay(yDoc, store, verdicts);
14775
+ const crossed = store.discontinuityEpochs();
14776
+ const boundary = crossed.length > 0 ? crossed[crossed.length - 1] : 0;
14777
+ 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) {
14785
+ Logger.warn(
14786
+ `[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
+ );
14788
+ }
13852
14789
  const adoptedWasRestored = verdicts !== void 0 && restored.restored.some((seq) => verdicts.has(seq));
13853
14790
  if (adoptedWasRestored) {
13854
14791
  foldFormat2Overlay(
@@ -14044,27 +14981,119 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14044
14981
  );
14045
14982
  }
14046
14983
  const hydrated = /* @__PURE__ */ new Set();
14984
+ const replacement = callbacks.plan;
14985
+ const touchedModels = /* @__PURE__ */ new Set();
14047
14986
  const result = await loadFormat2Snapshot({
14048
14987
  ...plan.kind === "capped" ? { models: plan.models } : {},
14049
14988
  store,
14050
14989
  manifest,
14051
14990
  fetchChunk,
14991
+ ...replacement ? { plan: replacement } : {},
14052
14992
  onProgress: callbacks.onProgress,
14993
+ onModelTouched: async (model) => {
14994
+ touchedModels.add(model);
14995
+ await store.forgetQueryProjection(model);
14996
+ await this.clearQueryRows(docId, store, model);
14997
+ await callbacks.onModelTouched?.(model);
14998
+ },
14053
14999
  onModelReady: async (model) => {
14054
- await this.hydrateQueryTables(docId, store, { only: model });
14055
- await this.whenProjectionSettled();
15000
+ const owed = replacement !== void 0 && !touchedModels.has(model) && !await store.queryProjectionIsCurrent(model);
15001
+ if (owed) {
15002
+ await this.clearQueryRows(docId, store, model);
15003
+ }
15004
+ if (!replacement || touchedModels.has(model) || owed) {
15005
+ await this.hydrateQueryTables(docId, store, { only: model });
15006
+ await this.whenProjectionSettled();
15007
+ }
14056
15008
  hydrated.add(model);
14057
15009
  await callbacks.onModelReady?.(model);
14058
15010
  }
14059
15011
  });
14060
- await this.hydrateQueryTables(docId, store, { except: hydrated });
14061
- await this.whenProjectionSettled();
15012
+ if (!replacement) {
15013
+ await this.hydrateQueryTables(docId, store, { except: hydrated });
15014
+ await this.whenProjectionSettled();
15015
+ }
14062
15016
  for (const model of result.repaired) {
14063
15017
  await this.hydrateQueryTables(docId, store, { only: model });
14064
15018
  await this.whenProjectionSettled();
14065
15019
  }
14066
15020
  return result;
14067
15021
  }
15022
+ /**
15023
+ * The models this client holds rows of for `docId` (#3435).
15024
+ *
15025
+ * The convergence planner needs an inventory, not a cap: a model the new
15026
+ * base has no chunk of is pruned whole, and `null` — "the whole document" —
15027
+ * cannot say which models those are. A capped device answers with its
15028
+ * hydration scope; an uncapped one answers with the models this instance
15029
+ * registered, which is every model whose rows could be here.
15030
+ */
15031
+ /**
15032
+ * Hydrate ONE model's derived query tables for a bound large document
15033
+ * (#3615).
15034
+ *
15035
+ * What a model registered on a running instance needs: the merged view
15036
+ * already holds its rows — the fold and the overlay observer are keyed by
15037
+ * the model name the overlay carries, not by the registry — but the derived
15038
+ * tables `query()` reads were never built, because the model was not in the
15039
+ * registry when the document was connected.
15040
+ *
15041
+ * Forced, because the point is a rebuild: whatever mark a previous session
15042
+ * left says nothing about tables this instance has never projected into.
15043
+ */
15044
+ async hydrateModel(docId, modelName) {
15045
+ const store = this.opened.get(docId);
15046
+ if (!store) return;
15047
+ if (!store.isHydrated(modelName)) {
15048
+ Logger.warn(
15049
+ `[DocumentManager] Large document ${docId} does not hold model '${modelName}' on this device (its hydration scope excludes it), so the query tables for '${modelName}' were not hydrated. Reads of '${modelName}' on ${docId} answer as they do for any model this device declined.`
15050
+ );
15051
+ return;
15052
+ }
15053
+ await this.hydrateQueryTables(docId, store, { only: modelName });
15054
+ await this.whenProjectionSettled();
15055
+ }
15056
+ localModels(docId) {
15057
+ const store = this.ormContext.getFormat2StoreAsync(docId);
15058
+ if (!store) return null;
15059
+ const scope = store.hydrationScope();
15060
+ if (scope !== null) return [...scope];
15061
+ const registered = [...this.modelRegistry.getActiveModels().keys()];
15062
+ return registered.length > 0 ? registered : null;
15063
+ }
15064
+ /**
15065
+ * Whether each of these records is in the merged view (#3435, behavior 21).
15066
+ *
15067
+ * What a convergence classifies its owed writes against. Read in one pass
15068
+ * per model, and only AFTER the ranges have been replaced and the overlays
15069
+ * refolded: before that, "absent" says nothing about what the bulk load did.
15070
+ *
15071
+ * `null` for a record of a model this device does not hold — no fact is
15072
+ * better than a wrong one, and the caller decides what a missing verdict
15073
+ * means.
15074
+ */
15075
+ async recordPresence(docId, records) {
15076
+ const store = this.ormContext.getFormat2StoreAsync(docId);
15077
+ if (!store) return records.map(() => null);
15078
+ const byModel = /* @__PURE__ */ new Map();
15079
+ records.forEach((record, index) => {
15080
+ const list = byModel.get(record.model);
15081
+ if (list) list.push(index);
15082
+ else byModel.set(record.model, [index]);
15083
+ });
15084
+ const answers = records.map(() => null);
15085
+ for (const [model, indexes] of byModel) {
15086
+ if (!store.isHydrated(model)) continue;
15087
+ const rows = await store.readMany(
15088
+ model,
15089
+ indexes.map((index) => records[index].id)
15090
+ );
15091
+ indexes.forEach((index, position) => {
15092
+ answers[index] = rows[position] !== null;
15093
+ });
15094
+ }
15095
+ return answers;
15096
+ }
14068
15097
  /**
14069
15098
  * Release `docId`. Safe on a document that was never format 2.
14070
15099
  *
@@ -14248,6 +15277,32 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14248
15277
  * holds. The sweep after it (`except`) and an ordinary connect honor the
14249
15278
  * mark.
14250
15279
  */
15280
+ /**
15281
+ * Delete one model's derived query rows for this document, a page at a time
15282
+ * (#3435).
15283
+ *
15284
+ * The clear half of clear-then-project. Paged from the record store while
15285
+ * the rows are still there, which is the same reason and the same shape as
15286
+ * the discard path: after a range replacement nothing names the records it
15287
+ * removed, and `hydrateQueryTables` only ever projects records that EXIST.
15288
+ */
15289
+ async clearQueryRows(docId, store, modelName) {
15290
+ const modelClass = this.modelRegistry.getModelClass(modelName);
15291
+ if (!modelClass) return;
15292
+ if (!store.isHydrated(modelName)) return;
15293
+ let after = null;
15294
+ for (; ; ) {
15295
+ const page = await store.recordIdsAfter(
15296
+ modelName,
15297
+ after,
15298
+ QUERY_HYDRATION_PAGE_ROWS
15299
+ );
15300
+ if (page.length === 0) break;
15301
+ await modelClass.deleteFormat2QueryRows(docId, page);
15302
+ after = page[page.length - 1];
15303
+ if (page.length < QUERY_HYDRATION_PAGE_ROWS) break;
15304
+ }
15305
+ }
14251
15306
  async hydrateQueryTables(docId, store, scope = {}) {
14252
15307
  for (const [, modelClass] of this.modelRegistry.getActiveModels()) {
14253
15308
  const modelName = modelClass.getSchema?.()?.options?.name;
@@ -14440,7 +15495,9 @@ function format2SyncMarks(docId, ctx) {
14440
15495
  highestLocalSeq: store.highestLocalSeq(),
14441
15496
  ackedSeq: store.ackedSeq(),
14442
15497
  epoch: store.epoch(),
14443
- clockOffsetKnown: store.clockOffsetKnown()
15498
+ clockOffsetKnown: store.clockOffsetKnown(),
15499
+ discontinuityEpochs: store.discontinuityEpochs(),
15500
+ baseComplete: store.baseComplete()
14444
15501
  };
14445
15502
  }
14446
15503
  function format2NoteEpoch(docId, epoch, ctx) {
@@ -14483,6 +15540,17 @@ function format2NoteClockOffset(docId, offsetMs, ctx) {
14483
15540
  if (!store) return;
14484
15541
  store.noteClockOffset(offsetMs);
14485
15542
  }
15543
+ async function format2NoteDiscontinuity(docId, epoch, ctx) {
15544
+ const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15545
+ if (!store) return;
15546
+ if (!Number.isInteger(epoch) || epoch <= 0) return;
15547
+ await store.noteDiscontinuity(epoch);
15548
+ }
15549
+ async function format2ClearDiscontinuities(docId, through, ctx) {
15550
+ const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
15551
+ if (!store) return;
15552
+ await store.clearDiscontinuities(through);
15553
+ }
14486
15554
  function format2ForgetPendingOps(docId, seqs, ctx) {
14487
15555
  const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
14488
15556
  if (!store) return;
@@ -14520,12 +15588,70 @@ var DocumentManagerImpl = class {
14520
15588
  modelRegistry;
14521
15589
  /** The ORM instance this manager belongs to (#3039). */
14522
15590
  ormContext;
15591
+ /**
15592
+ * The registration gate (#3615).
15593
+ *
15594
+ * `connectDocument` finishes its model loop BEFORE it publishes the document
15595
+ * in `connectedDocuments`, so a registration that read the map in that
15596
+ * interval would miss the document while its own loop had already run —
15597
+ * leaving it connected with no table, no rows and no observer for the new
15598
+ * model. Registrations therefore run one at a time on this chain; a connect,
15599
+ * a rebind and a disconnect wait for it at entry, and a registration waits
15600
+ * for the connects that got in before it ({@link activeConnects}) before it
15601
+ * reads the map. Connects of different documents that no registration
15602
+ * overlaps still run concurrently, as they always have.
15603
+ */
15604
+ registrationChain = Promise.resolve();
15605
+ /** Connects past the gate and not yet published — what a registration waits for. */
15606
+ activeConnects = /* @__PURE__ */ new Set();
15607
+ /**
15608
+ * The `(model, document)` pairs whose initialization for this instance
15609
+ * COMPLETED (#3615).
15610
+ *
15611
+ * Registry membership alone cannot say it: a document-level failure leaves
15612
+ * the class registered but that document unusable, and `initializeForDocument`
15613
+ * clears a document's rows and installs a fresh observer on every run, so a
15614
+ * blanket retry would wipe the documents that had succeeded and double their
15615
+ * observers. A retry resumes with the pairs that are not in here.
15616
+ */
15617
+ runtimeInitialized = /* @__PURE__ */ new Map();
15618
+ /**
15619
+ * Pass the registration gate, then run `work` as an in-flight connect
15620
+ * (#3615).
15621
+ *
15622
+ * The re-read after the await is what closes the window: a registration
15623
+ * publishes its chain synchronously when it is called, so a caller that
15624
+ * waited on the chain it saw first has to check whether another one was
15625
+ * queued behind it. Joining {@link activeConnects} happens with no await in
15626
+ * between, so nothing can slip between the check and the join.
15627
+ */
15628
+ async enterConnect(work) {
15629
+ for (; ; ) {
15630
+ const chain = this.registrationChain;
15631
+ await chain;
15632
+ if (this.registrationChain === chain) break;
15633
+ }
15634
+ let settle;
15635
+ const ticket = new Promise((resolve) => settle = resolve);
15636
+ this.activeConnects.add(ticket);
15637
+ try {
15638
+ return await work();
15639
+ } finally {
15640
+ this.activeConnects.delete(ticket);
15641
+ settle();
15642
+ }
15643
+ }
14523
15644
  async connectDocument(docId, yDoc, permissionHint, options) {
15645
+ return this.enterConnect(
15646
+ () => this.connectDocumentInner(docId, yDoc, permissionHint, options)
15647
+ );
15648
+ }
15649
+ async connectDocumentInner(docId, yDoc, permissionHint, options) {
14524
15650
  if (this.connectedDocuments.has(docId)) {
14525
15651
  console.warn(
14526
15652
  `[DocumentManager] Document ${docId} is already connected. Disconnecting old version.`
14527
15653
  );
14528
- await this.disconnectDocument(docId);
15654
+ await this.disconnectDocumentInner(docId);
14529
15655
  }
14530
15656
  const documentFormat = options?.documentFormat ?? 1;
14531
15657
  const connectedDoc = {
@@ -14552,6 +15678,9 @@ var DocumentManagerImpl = class {
14552
15678
  await this.format2.connect(docId, yDoc, options?.epoch);
14553
15679
  }
14554
15680
  this.connectedDocuments.set(docId, connectedDoc);
15681
+ for (const modelName of this.modelRegistry.getActiveModels().keys()) {
15682
+ this.markRuntimeInitialized(modelName, docId);
15683
+ }
14555
15684
  this.notifyConnectionChange({
14556
15685
  type: "connect",
14557
15686
  docId,
@@ -14570,6 +15699,11 @@ var DocumentManagerImpl = class {
14570
15699
  * models read and write through is exchanged.
14571
15700
  */
14572
15701
  async rebindEpochDocument(docId, yDoc, options = {}) {
15702
+ return this.enterConnect(
15703
+ () => this.rebindEpochDocumentInner(docId, yDoc, options)
15704
+ );
15705
+ }
15706
+ async rebindEpochDocumentInner(docId, yDoc, options = {}) {
14573
15707
  const connected = this.connectedDocuments.get(docId);
14574
15708
  if (!connected) {
14575
15709
  throw new Error(
@@ -14666,7 +15800,27 @@ var DocumentManagerImpl = class {
14666
15800
  this.largeDocumentStorage
14667
15801
  );
14668
15802
  }
15803
+ /**
15804
+ * The models this instance holds rows of for `docId` (#3435) — the inventory
15805
+ * a convergence plan decides its prunes against.
15806
+ */
15807
+ localModels(docId) {
15808
+ const connected = this.connectedDocuments.get(docId);
15809
+ if (!connected || connected.documentFormat !== 2) return null;
15810
+ return this.format2.localModels(docId);
15811
+ }
15812
+ /** Whether each record is in `docId`'s merged view (#3435, behavior 21). */
15813
+ async recordPresence(docId, records) {
15814
+ const connected = this.connectedDocuments.get(docId);
15815
+ if (!connected || connected.documentFormat !== 2) {
15816
+ return records.map(() => null);
15817
+ }
15818
+ return await this.format2.recordPresence(docId, records);
15819
+ }
14669
15820
  async disconnectDocument(docId) {
15821
+ return this.enterConnect(() => this.disconnectDocumentInner(docId));
15822
+ }
15823
+ async disconnectDocumentInner(docId) {
14670
15824
  const connectedDoc = this.connectedDocuments.get(docId);
14671
15825
  if (!connectedDoc) {
14672
15826
  Logger.warn(`[DocumentManager] Document ${docId} is not connected`);
@@ -14683,6 +15837,9 @@ var DocumentManagerImpl = class {
14683
15837
  wipeModels ? { wipeIfLastModels: wipeModels } : {}
14684
15838
  );
14685
15839
  this.connectedDocuments.delete(docId);
15840
+ for (const docIds of this.runtimeInitialized.values()) {
15841
+ docIds.delete(docId);
15842
+ }
14686
15843
  this.ormContext.disposeObserversForDocument(docId);
14687
15844
  this.ormContext.clearMappingsForDocId(docId);
14688
15845
  this.notifyConnectionChange({
@@ -14693,10 +15850,150 @@ var DocumentManagerImpl = class {
14693
15850
  `[DocumentManager] Document ${docId} disconnected successfully`
14694
15851
  );
14695
15852
  }
15853
+ markRuntimeInitialized(modelName, docId) {
15854
+ let docIds = this.runtimeInitialized.get(modelName);
15855
+ if (!docIds) {
15856
+ docIds = /* @__PURE__ */ new Set();
15857
+ this.runtimeInitialized.set(modelName, docIds);
15858
+ }
15859
+ docIds.add(docId);
15860
+ }
15861
+ /**
15862
+ * Add a model to this running instance (#3615). See
15863
+ * {@link InitJsBaoResult.registerModel}.
15864
+ */
15865
+ async registerModel(modelClass) {
15866
+ const previous = this.registrationChain;
15867
+ let release;
15868
+ this.registrationChain = new Promise((resolve) => release = resolve);
15869
+ try {
15870
+ await previous;
15871
+ while (this.activeConnects.size > 0) {
15872
+ await Promise.all(Array.from(this.activeConnects));
15873
+ }
15874
+ await this.runRegistration(modelClass);
15875
+ } finally {
15876
+ release();
15877
+ }
15878
+ }
15879
+ async runRegistration(modelClass) {
15880
+ if (this.ormContext.destroyed) {
15881
+ throw new Error(
15882
+ `[initJsBao] Cannot register model on destroyed js-bao instance #${this.ormContext.instanceId}. Create a new instance with initJsBao.`
15883
+ );
15884
+ }
15885
+ const modelName = this.modelRegistry.assertAdoptable(
15886
+ modelClass,
15887
+ this.ormContext.instanceId
15888
+ );
15889
+ const boundTo = modelClass?._ormContext;
15890
+ if (boundTo && boundTo !== this.ormContext && !boundTo.destroyed) {
15891
+ throw new Error(
15892
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${boundTo.instanceId}. Give instance #${this.ormContext.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
15893
+ );
15894
+ }
15895
+ const { added } = this.modelRegistry.adoptModel(modelClass, {
15896
+ instanceId: this.ormContext.instanceId
15897
+ });
15898
+ bindModelClass(this.ormContext, modelName, modelClass, "throw");
15899
+ let didDocumentWork = false;
15900
+ for (const connected of Array.from(this.connectedDocuments.values())) {
15901
+ if (this.runtimeInitialized.get(modelName)?.has(connected.docId)) {
15902
+ continue;
15903
+ }
15904
+ didDocumentWork = true;
15905
+ try {
15906
+ await this.initializeModelForConnectedDocument(
15907
+ modelClass,
15908
+ modelName,
15909
+ connected
15910
+ );
15911
+ } catch (error) {
15912
+ await modelClass.withdrawDocumentRegistration(connected.docId);
15913
+ const message = error instanceof Error ? error.message : String(error);
15914
+ const wrapped = new Error(
15915
+ `[initJsBao] registerModel('${modelName}') failed on document ${connected.docId}; registration is incomplete and can be retried: ` + message
15916
+ );
15917
+ wrapped.cause = error;
15918
+ throw wrapped;
15919
+ }
15920
+ this.markRuntimeInitialized(modelName, connected.docId);
15921
+ }
15922
+ if (added || didDocumentWork) {
15923
+ await this.modelRegistry.initializeRelationships(this.dbEngine);
15924
+ }
15925
+ }
15926
+ /**
15927
+ * Initialize one model for one already-connected document (#3615).
15928
+ *
15929
+ * The legacy half carries the window `initializeForDocument` leaves open: it
15930
+ * loads the Y.Map as records in an asynchronous transaction and installs its
15931
+ * observers afterwards, which on a document the app connected long ago is a
15932
+ * live document still receiving peer updates. A temporary deep observer
15933
+ * collects the record ids touched while that runs; once the instance's
15934
+ * indexing for the document has drained, each of them is written from its
15935
+ * CURRENT state, so the call resolves with the query tables holding every
15936
+ * update that arrived before it did.
15937
+ */
15938
+ async initializeModelForConnectedDocument(modelClass, modelName, connected) {
15939
+ const { docId, yDoc, permissionHint } = connected;
15940
+ const documentFormat = connected.documentFormat ?? 1;
15941
+ if (documentFormat === 2) {
15942
+ await modelClass.initializeForDocument(
15943
+ yDoc,
15944
+ this.dbEngine,
15945
+ docId,
15946
+ permissionHint,
15947
+ 2
15948
+ );
15949
+ await this.format2.hydrateModel(docId, modelName);
15950
+ return;
15951
+ }
15952
+ const touched = /* @__PURE__ */ new Set();
15953
+ const documentYMap = yDoc.getMap(modelName);
15954
+ const collect2 = (events) => {
15955
+ for (const event of events) {
15956
+ if (event.target === documentYMap) {
15957
+ for (const key of event.changes.keys.keys()) touched.add(key);
15958
+ } else if (event.path.length > 0) {
15959
+ touched.add(String(event.path[0]));
15960
+ }
15961
+ }
15962
+ };
15963
+ documentYMap.observeDeep(collect2);
15964
+ try {
15965
+ await modelClass.initializeForDocument(
15966
+ yDoc,
15967
+ this.dbEngine,
15968
+ docId,
15969
+ permissionHint,
15970
+ 1
15971
+ );
15972
+ await this.whenDocumentIndexed(docId);
15973
+ for (const recordId of touched) {
15974
+ await modelClass.reindexDocumentRecord(docId, recordId);
15975
+ }
15976
+ } finally {
15977
+ documentYMap.unobserveDeep(collect2);
15978
+ }
15979
+ }
14696
15980
  /** Resolves once every enqueued query-projection refresh has been applied. */
14697
15981
  whenFormat2ProjectionSettled() {
14698
15982
  return this.format2.whenProjectionSettled();
14699
15983
  }
15984
+ /**
15985
+ * Resolves once THIS instance's query tables have caught up with `docId`
15986
+ * (#3023). Scoped to the instance's own context, so a second client holding
15987
+ * the same document id never waits on this one's writes.
15988
+ */
15989
+ async whenDocumentIndexed(docId) {
15990
+ const drained = await this.ormContext.whenDocumentIndexed(docId);
15991
+ if (!drained) {
15992
+ Logger.warn(
15993
+ `[DocumentManager] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
15994
+ );
15995
+ }
15996
+ }
14700
15997
  getConnectedDocuments() {
14701
15998
  return new Map(this.connectedDocuments);
14702
15999
  }
@@ -14724,22 +16021,33 @@ var DocumentManagerImpl = class {
14724
16021
  };
14725
16022
  function bindSessionModels(ctx, registry) {
14726
16023
  for (const [modelName, modelClass] of registry.getActiveModels()) {
14727
- const current = modelClass?._ormContext;
14728
- if (current === ctx) continue;
14729
- if (!current || current.destroyed) {
14730
- Object.defineProperty(modelClass, "_ormContext", {
14731
- value: ctx,
14732
- writable: true,
14733
- configurable: true,
14734
- enumerable: false
14735
- });
14736
- ctx.boundModels.add(modelClass);
14737
- continue;
14738
- }
14739
- console.warn(
14740
- `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
16024
+ bindModelClass(ctx, modelName, modelClass, "warn");
16025
+ }
16026
+ }
16027
+ function bindModelClass(ctx, modelName, modelClass, onLiveConflict) {
16028
+ const current = modelClass?._ormContext;
16029
+ if (current === ctx) {
16030
+ ctx.boundModels.add(modelClass);
16031
+ return;
16032
+ }
16033
+ if (!current || current.destroyed) {
16034
+ Object.defineProperty(modelClass, "_ormContext", {
16035
+ value: ctx,
16036
+ writable: true,
16037
+ configurable: true,
16038
+ enumerable: false
16039
+ });
16040
+ ctx.boundModels.add(modelClass);
16041
+ return;
16042
+ }
16043
+ if (onLiveConflict === "throw") {
16044
+ throw new Error(
16045
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${current.instanceId}. Give instance #${ctx.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
14741
16046
  );
14742
16047
  }
16048
+ console.warn(
16049
+ `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
16050
+ );
14743
16051
  }
14744
16052
  async function initJsBaoCore(options, getEngine) {
14745
16053
  const { databaseConfig, models: modelClasses } = options;
@@ -14768,6 +16076,7 @@ async function initJsBaoCore(options, getEngine) {
14768
16076
  destroy: () => ormContext.destroy(),
14769
16077
  connectDocument: docManager.connectDocument.bind(docManager),
14770
16078
  disconnectDocument: docManager.disconnectDocument.bind(docManager),
16079
+ registerModel: docManager.registerModel.bind(docManager),
14771
16080
  format2SyncMarks: (docId) => format2SyncMarks(docId, ormContext),
14772
16081
  format2StoredEpoch: (docId) => format2StoredEpoch(dbEngine, docId, ormContext),
14773
16082
  // The pre-bind epoch read opens the same pool the bind will, so it needs
@@ -14785,6 +16094,8 @@ async function initJsBaoCore(options, getEngine) {
14785
16094
  format2NoteWindow: (docId, windowDays) => format2NoteWindow(docId, windowDays, ormContext),
14786
16095
  format2NoteClockOffset: (docId, offsetMs) => format2NoteClockOffset(docId, offsetMs, ormContext),
14787
16096
  format2ForgetPendingOps: (docId, seqs) => format2ForgetPendingOps(docId, seqs, ormContext),
16097
+ format2NoteDiscontinuity: (docId, epoch) => format2NoteDiscontinuity(docId, epoch, ormContext),
16098
+ format2ClearDiscontinuities: (docId, through) => format2ClearDiscontinuities(docId, through, ormContext),
14788
16099
  format2NarrowPendingOps: (docId, ops) => format2NarrowPendingOps(docId, ops, ormContext),
14789
16100
  format2PendingOps: (docId) => format2PendingOps(docId, ormContext),
14790
16101
  format2SetEpoch: (docId, epoch) => format2SetEpoch(docId, epoch, ormContext),
@@ -14798,7 +16109,10 @@ async function initJsBaoCore(options, getEngine) {
14798
16109
  // queue updates after it). The transport awaits it before an update
14799
16110
  // from the room counts as applied.
14800
16111
  whenFormat2ProjectionSettled: docManager.whenFormat2ProjectionSettled.bind(docManager),
16112
+ whenDocumentIndexed: docManager.whenDocumentIndexed.bind(docManager),
14801
16113
  format2LoadSnapshot: docManager.loadSnapshot.bind(docManager),
16114
+ format2LocalModels: (docId) => docManager.localModels(docId),
16115
+ format2RecordPresence: (docId, records) => docManager.recordPresence(docId, records),
14802
16116
  getConnectedDocuments: docManager.getConnectedDocuments.bind(docManager),
14803
16117
  isDocumentConnected: docManager.isDocumentConnected.bind(docManager),
14804
16118
  onDocumentConnectionChange: docManager.onConnectionChange.bind(docManager),
@@ -14958,40 +16272,6 @@ function createModelClass(config) {
14958
16272
  attachAndRegisterModel(GeneratedModel, schema);
14959
16273
  return GeneratedModel;
14960
16274
  }
14961
- function resolveUniqueConstraints(modelName, fields, customConstraints) {
14962
- const resolved = [];
14963
- for (const [fieldName, options] of fields.entries()) {
14964
- if (options.unique) {
14965
- resolved.push({
14966
- name: fieldUniqueConstraintName(modelName, fieldName),
14967
- fields: [fieldName]
14968
- });
14969
- }
14970
- }
14971
- if (customConstraints) {
14972
- for (const constraint of customConstraints) {
14973
- const missingField = constraint.fields.find(
14974
- (field) => !fields.has(field)
14975
- );
14976
- if (missingField) {
14977
- console.warn(
14978
- `[defineModelSchema] Unique constraint "${constraint.name}" for model "${modelName}" references unknown field "${missingField}". Skipping.`
14979
- );
14980
- continue;
14981
- }
14982
- if (resolved.some((item) => item.name === constraint.name)) {
14983
- console.warn(
14984
- `[defineModelSchema] Duplicate unique constraint name "${constraint.name}" in model "${modelName}".`
14985
- );
14986
- }
14987
- resolved.push({
14988
- name: constraint.name,
14989
- fields: [...constraint.fields]
14990
- });
14991
- }
14992
- }
14993
- return resolved;
14994
- }
14995
16275
  function attachSchemaToClass(modelClass, schema) {
14996
16276
  modelClass.schema = schema;
14997
16277
  modelClass.modelName = schema.options.name;
@@ -15233,6 +16513,26 @@ function schemaToToml(schema) {
15233
16513
 
15234
16514
  // src/models/tomlLoader.ts
15235
16515
  var import_smol_toml = require("smol-toml");
16516
+
16517
+ // src/utils/reservedFields.ts
16518
+ function checkReservedFieldName(fieldName) {
16519
+ if (fieldName === "id") return null;
16520
+ if (fieldName === "type") {
16521
+ return `Field 'type' is reserved (maps to internal _type column in queries)`;
16522
+ }
16523
+ if (fieldName.startsWith("_")) {
16524
+ return `Field '${fieldName}' is reserved (fields starting with '_' are internal)`;
16525
+ }
16526
+ return null;
16527
+ }
16528
+ var ReservedFieldNameError = class extends Error {
16529
+ constructor(message) {
16530
+ super(message);
16531
+ this.name = "ReservedFieldNameError";
16532
+ }
16533
+ };
16534
+
16535
+ // src/models/tomlLoader.ts
15236
16536
  var VALID_FIELD_TYPES = /* @__PURE__ */ new Set([
15237
16537
  "string",
15238
16538
  "number",
@@ -15379,6 +16679,7 @@ function parseRelationship(raw, context, strict) {
15379
16679
  }
15380
16680
  function loadSchemaFromTomlString(tomlString, options = {}) {
15381
16681
  const strict = options.strict !== false;
16682
+ const rejectReserved = options.rejectReservedFieldNames === true;
15382
16683
  const parsed = (0, import_smol_toml.parse)(tomlString);
15383
16684
  const models = parsed.models;
15384
16685
  if (!models || typeof models !== "object") {
@@ -15395,11 +16696,14 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
15395
16696
  const fields = {};
15396
16697
  if (modelDef.fields) {
15397
16698
  for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
15398
- fields[fieldName] = parseFieldOptions(
15399
- fieldDef,
15400
- `[models.${modelName}.fields.${fieldName}]`,
15401
- strict
15402
- );
16699
+ const context = `[models.${modelName}.fields.${fieldName}]`;
16700
+ if (rejectReserved) {
16701
+ const reserved = checkReservedFieldName(fieldName);
16702
+ if (reserved) {
16703
+ throw new ReservedFieldNameError(`${context}: ${reserved}`);
16704
+ }
16705
+ }
16706
+ fields[fieldName] = parseFieldOptions(fieldDef, context, strict);
15403
16707
  }
15404
16708
  }
15405
16709
  let relationships;
@@ -15446,6 +16750,18 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
15446
16750
  }
15447
16751
  return schemas;
15448
16752
  }
16753
+ function findReservedFieldNameInToml(tomlString) {
16754
+ try {
16755
+ loadSchemaFromTomlString(tomlString, {
16756
+ strict: false,
16757
+ rejectReservedFieldNames: true
16758
+ });
16759
+ return null;
16760
+ } catch (error) {
16761
+ if (error instanceof ReservedFieldNameError) return error.message;
16762
+ return null;
16763
+ }
16764
+ }
15449
16765
 
15450
16766
  // src/browser.ts
15451
16767
  init_metaSync();
@@ -15542,6 +16858,49 @@ function writeCarriedOverlay(modelMap, entry) {
15542
16858
  }
15543
16859
  }
15544
16860
  }
16861
+ function suppressSupersededKeys(carried, later) {
16862
+ if (later.length === 0 || carried.length === 0) return carried;
16863
+ const fields = /* @__PURE__ */ new Map();
16864
+ const revived = /* @__PURE__ */ new Set();
16865
+ for (const op of later) {
16866
+ const key = `${op.model} ${op.recordId}`;
16867
+ let owned = fields.get(key);
16868
+ if (!owned) fields.set(key, owned = /* @__PURE__ */ new Set());
16869
+ for (const field of op.fields) owned.add(field);
16870
+ if (op.op !== "delete") revived.add(key);
16871
+ }
16872
+ const kept = [];
16873
+ for (const { model, entry } of carried) {
16874
+ const key = `${model} ${entry.id}`;
16875
+ const owned = fields.get(key);
16876
+ if (owned === void 0) {
16877
+ kept.push({ model, entry });
16878
+ continue;
16879
+ }
16880
+ if (entry.deleted) {
16881
+ if (!revived.has(key)) kept.push({ model, entry });
16882
+ continue;
16883
+ }
16884
+ const survivingFields = Object.fromEntries(
16885
+ Object.entries(entry.fields).filter(([field]) => !owned.has(field))
16886
+ );
16887
+ const survivingSets = Object.fromEntries(
16888
+ Object.entries(entry.stringSets).filter(([field]) => !owned.has(field))
16889
+ );
16890
+ if (!entry.replace && Object.keys(survivingFields).length === 0 && Object.keys(survivingSets).length === 0) {
16891
+ continue;
16892
+ }
16893
+ kept.push({
16894
+ model,
16895
+ entry: {
16896
+ ...entry,
16897
+ fields: survivingFields,
16898
+ stringSets: survivingSets
16899
+ }
16900
+ });
16901
+ }
16902
+ return kept;
16903
+ }
15545
16904
 
15546
16905
  // src/models/format2EpochRotation.ts
15547
16906
  var Y10 = __toESM(require("yjs"), 1);
@@ -15613,10 +16972,15 @@ async function writeDeferredReplay(host, ops, owedOverlay, resolveReplay) {
15613
16972
  const live = host.overlayDoc();
15614
16973
  if (!live) return 0;
15615
16974
  const owed = resolveReplay(ops);
15616
- const carried = carryUnackedOverlay(
15617
- owed.ops,
15618
- (model, recordId) => owedOverlay.get(`${model} ${recordId}`) ?? readOverlayRecord(live.getMap(model), recordId),
15619
- { suppressDeletes: owed.suppressDeletes }
16975
+ const held = new Set(ops.map((op) => op.seq));
16976
+ const later = host.pendingOps().filter((op) => !held.has(op.seq));
16977
+ const carried = suppressSupersededKeys(
16978
+ carryUnackedOverlay(
16979
+ owed.ops,
16980
+ (model, recordId) => owedOverlay.get(`${model} ${recordId}`) ?? readOverlayRecord(live.getMap(model), recordId),
16981
+ { suppressDeletes: owed.suppressDeletes }
16982
+ ),
16983
+ later
15620
16984
  );
15621
16985
  if (carried.length === 0) return 0;
15622
16986
  live.transact(() => {
@@ -15640,18 +17004,22 @@ function planFastForward(options) {
15640
17004
  if (target <= current) return { kind: "none", reason: "current" };
15641
17005
  const byEpoch = /* @__PURE__ */ new Map();
15642
17006
  for (const entry of options.sealed) byEpoch.set(entry.epoch, entry);
17007
+ const discontinuities = options.sealed.filter(
17008
+ (entry) => entry.baseDiscontinuity && entry.epoch >= current && entry.epoch < target
17009
+ ).map((entry) => entry.epoch).sort((a, b) => a - b);
17010
+ const last = discontinuities.length > 0 ? discontinuities[0] : target - 1;
15643
17011
  const steps = [];
15644
- for (let epoch = current; epoch < target; epoch++) {
17012
+ for (let epoch = current; epoch <= last; epoch++) {
15645
17013
  const entry = byEpoch.get(epoch);
15646
17014
  if (!entry) return { kind: "reload", reason: "gap", epoch };
15647
- if (entry.baseDiscontinuity) {
15648
- return { kind: "reload", reason: "discontinuity", epoch };
15649
- }
15650
17015
  if (entry.download === void 0 || entry.download === null) {
15651
17016
  return { kind: "reload", reason: "unavailable", epoch };
15652
17017
  }
15653
17018
  steps.push({ epoch, download: entry.download });
15654
17019
  }
17020
+ if (discontinuities.length > 0) {
17021
+ return { kind: "converge", from: current, discontinuities, steps };
17022
+ }
15655
17023
  return { kind: "apply", from: current, to: target, steps };
15656
17024
  }
15657
17025
  async function runFastForward(host, options) {
@@ -15660,6 +17028,14 @@ async function runFastForward(host, options) {
15660
17028
  if (plan.kind === "reload") {
15661
17029
  return { outcome: "reload", reason: plan.reason, epoch: plan.epoch };
15662
17030
  }
17031
+ if (plan.kind === "converge") {
17032
+ return {
17033
+ outcome: "converge",
17034
+ from: plan.from,
17035
+ discontinuities: plan.discontinuities,
17036
+ steps: plan.steps
17037
+ };
17038
+ }
15663
17039
  let applied;
15664
17040
  try {
15665
17041
  applied = await host.hold(() => applyFastForward(plan, host));
@@ -15697,6 +17073,10 @@ async function applyFastForward(plan, host) {
15697
17073
  }
15698
17074
 
15699
17075
  // src/models/format2ColdStart.ts
17076
+ function isGranted(sealed, epoch) {
17077
+ const entry = sealed.find((each) => each.epoch === epoch);
17078
+ return !!entry && entry.download !== void 0 && entry.download !== null;
17079
+ }
15700
17080
  function chainIsWhole(sealed, from, to) {
15701
17081
  const byEpoch = /* @__PURE__ */ new Map();
15702
17082
  for (const entry of sealed) byEpoch.set(entry.epoch, entry);
@@ -15722,6 +17102,15 @@ function planColdStart(input) {
15722
17102
  if (first === 1 && chainIsWhole(sealed, 1, reported)) {
15723
17103
  return { kind: "overlays", base: 1 };
15724
17104
  }
17105
+ const start = Number.isInteger(base) && base > 0 ? base : 1;
17106
+ const discontinuities = sealed.filter(
17107
+ (entry) => entry.baseDiscontinuity && entry.epoch >= start && entry.epoch < reported
17108
+ ).map((entry) => entry.epoch);
17109
+ if (discontinuities.length > 0 && // Whole and granted up to the first flagged epoch, and no further: past it
17110
+ // the base that is coming covers everything.
17111
+ chainIsWhole(sealed, start, discontinuities[0]) && isGranted(sealed, discontinuities[0])) {
17112
+ return { kind: "await-base", discontinuities };
17113
+ }
15725
17114
  return { kind: "unavailable", reason: "no-base" };
15726
17115
  }
15727
17116
  async function runColdStart(host, plan, options = {}) {
@@ -15733,6 +17122,8 @@ async function runColdStart(host, plan, options = {}) {
15733
17122
  return true;
15734
17123
  case "unavailable":
15735
17124
  return false;
17125
+ case "await-base":
17126
+ return false;
15736
17127
  case "snapshot":
15737
17128
  case "overlays": {
15738
17129
  if (options.stale) await host.discard();
@@ -15753,11 +17144,69 @@ async function runColdStart(host, plan, options = {}) {
15753
17144
  }
15754
17145
  }
15755
17146
 
17147
+ // src/models/format2RangeReplacement.ts
17148
+ init_snapshotChunks();
17149
+ function planRangeReplacement(options) {
17150
+ const { manifest, touched } = options;
17151
+ const scope = options.localModels;
17152
+ const inScope = (model) => scope === null || scope.includes(model);
17153
+ const perModel = /* @__PURE__ */ new Map();
17154
+ for (const chunk of manifest.chunks) {
17155
+ if (!inScope(chunk.model)) continue;
17156
+ const list = perModel.get(chunk.model);
17157
+ if (list) list.push(chunk);
17158
+ else perModel.set(chunk.model, [chunk]);
17159
+ }
17160
+ const touchedPerModel = /* @__PURE__ */ new Map();
17161
+ for (const range of touched?.ranges ?? []) {
17162
+ const list = touchedPerModel.get(range.model);
17163
+ if (list) list.push(range);
17164
+ else touchedPerModel.set(range.model, [range]);
17165
+ }
17166
+ const fetch = [];
17167
+ const keep = [];
17168
+ const order = new Map(manifest.chunks.map((chunk, index) => [chunk, index]));
17169
+ for (const [model, chunks] of perModel) {
17170
+ const ranges = touchedPerModel.get(model) ?? [];
17171
+ for (let index = 0; index < chunks.length; index++) {
17172
+ const chunk = chunks[index];
17173
+ const coverage = {
17174
+ after: index === 0 ? null : chunks[index - 1].lastId,
17175
+ through: index === chunks.length - 1 ? null : chunk.lastId
17176
+ };
17177
+ if (touched === null || ranges.some((range) => meets(coverage, range))) {
17178
+ fetch.push({ chunk, coverage });
17179
+ } else {
17180
+ keep.push(chunk);
17181
+ }
17182
+ }
17183
+ }
17184
+ fetch.sort((a, b) => order.get(a.chunk) - order.get(b.chunk));
17185
+ keep.sort((a, b) => order.get(a) - order.get(b));
17186
+ const absent = /* @__PURE__ */ new Set();
17187
+ for (const model of scope ?? []) absent.add(model);
17188
+ for (const model of touchedPerModel.keys()) {
17189
+ if (inScope(model)) absent.add(model);
17190
+ }
17191
+ const pruneModels = [...absent].filter((model) => !perModel.has(model)).sort();
17192
+ return { fetch, keep, pruneModels, full: touched === null };
17193
+ }
17194
+ function meets(coverage, range) {
17195
+ if (coverage.through !== null && compareRecordIds(range.firstId, coverage.through) > 0) {
17196
+ return false;
17197
+ }
17198
+ if (coverage.after !== null && compareRecordIds(range.lastId, coverage.after) <= 0) {
17199
+ return false;
17200
+ }
17201
+ return true;
17202
+ }
17203
+
15756
17204
  // src/models/format2OfflineReplay.ts
15757
17205
  var Y12 = __toESM(require("yjs"), 1);
15758
17206
  init_largeDocuments();
15759
17207
  init_format2Observer();
15760
17208
  var recordKey = (model, recordId) => `${model}\0${recordId}`;
17209
+ var offlineReplayRecordKey = recordKey;
15761
17210
  var OfflineConflictLedger = class {
15762
17211
  /** Latest touch per record per epoch, newest epoch last. */
15763
17212
  touches = /* @__PURE__ */ new Map();
@@ -15900,6 +17349,40 @@ function resolveOfflineReplay(options) {
15900
17349
  };
15901
17350
  for (const op of [...options.ops].sort((a, b) => a.seq - b.seq)) {
15902
17351
  const base = { model: op.model, recordId: op.recordId, op: op.op, ts: op.ts };
17352
+ const ingest = options.ingest;
17353
+ if (ingest && op.baseEpoch <= ingest.through) {
17354
+ const presence = ingest.records.get(recordKey(op.model, op.recordId));
17355
+ const verdict = presence ?? (ingest.scope === "all" ? "present" : void 0);
17356
+ if (verdict === "present") {
17357
+ ops.push(op);
17358
+ notices.push({
17359
+ ...base,
17360
+ outcome: "kept-ambiguous",
17361
+ reason: "bulkIngest",
17362
+ epoch: ingest.through
17363
+ });
17364
+ continue;
17365
+ }
17366
+ if (verdict === "absent") {
17367
+ if (op.op === "create") {
17368
+ ops.push(op);
17369
+ notices.push({
17370
+ ...base,
17371
+ outcome: "kept-ambiguous",
17372
+ reason: "bulkIngest",
17373
+ epoch: ingest.through
17374
+ });
17375
+ continue;
17376
+ }
17377
+ notices.push({
17378
+ ...base,
17379
+ outcome: "dropped",
17380
+ reason: "bulkIngest",
17381
+ epoch: ingest.through
17382
+ });
17383
+ continue;
17384
+ }
17385
+ }
15903
17386
  if (!ledger.covers(op.baseEpoch, currentEpoch)) {
15904
17387
  ops.push(op);
15905
17388
  notices.push({
@@ -16021,8 +17504,10 @@ function resolveOfflineReplay(options) {
16021
17504
 
16022
17505
  // src/browser.ts
16023
17506
  init_format2OfflineWindow();
17507
+ init_snapshotChunks();
16024
17508
 
16025
17509
  // src/utils/snapshotPartition.ts
17510
+ init_snapshotChunks();
16026
17511
  var DEFAULT_SNAPSHOT_PARTITION = {
16027
17512
  minBytes: 6 * 1024 * 1024,
16028
17513
  maxBytes: 12 * 1024 * 1024,
@@ -16188,6 +17673,19 @@ var SnapshotPartitioner = class {
16188
17673
  get pendingBytes() {
16189
17674
  return this.bufferBytes;
16190
17675
  }
17676
+ /**
17677
+ * Whether the buffer is a below-minimum range being carried forward (#3433).
17678
+ *
17679
+ * An incremental build walks only the ranges that changed, so it has to know
17680
+ * when a range it re-cut closed short and merged into its SUCCESSOR: that
17681
+ * successor is then part of the walk too, and cannot be reused by reference.
17682
+ * Reading it off the partitioner rather than inferring it from the emitted
17683
+ * chunks keeps one answer to "did this boundary move", in the object that
17684
+ * decided it.
17685
+ */
17686
+ get carriedTail() {
17687
+ return this.merging;
17688
+ }
16191
17689
  take(count) {
16192
17690
  const taken = count === void 0 ? this.buffer : this.buffer.slice(0, count);
16193
17691
  const rest = count === void 0 ? [] : this.buffer.slice(count);