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