js-bao 0.7.0-alpha.7 → 0.7.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.cjs +1546 -89
- package/dist/browser.d.cts +622 -94
- package/dist/browser.d.ts +622 -94
- package/dist/browser.js +1546 -89
- package/dist/client.cjs +63 -0
- package/dist/client.d.cts +410 -2
- package/dist/client.d.ts +410 -2
- package/dist/client.js +63 -0
- package/dist/cloudflare-do.cjs +563 -56
- package/dist/cloudflare-do.d.cts +648 -3
- package/dist/cloudflare-do.d.ts +648 -3
- package/dist/cloudflare-do.js +563 -56
- package/dist/cloudflare.cjs +77 -0
- package/dist/cloudflare.d.cts +426 -3
- package/dist/cloudflare.d.ts +426 -3
- package/dist/cloudflare.js +77 -0
- package/dist/codegen-v2.cjs +39 -0
- package/dist/format2-worker.js +456 -10
- package/dist/index.cjs +1549 -90
- package/dist/index.d.cts +632 -94
- package/dist/index.d.ts +632 -94
- package/dist/index.js +1547 -90
- package/dist/node.cjs +1549 -90
- package/dist/node.d.cts +632 -94
- package/dist/node.d.ts +632 -94
- package/dist/node.js +1547 -90
- 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
|
*
|
|
@@ -5523,6 +6122,35 @@ var init_OrmContext = __esm({
|
|
|
5523
6122
|
this.modelDocMappingChangedListeners.add(listener);
|
|
5524
6123
|
return () => this.modelDocMappingChangedListeners.delete(listener);
|
|
5525
6124
|
}
|
|
6125
|
+
/** Hear every write this instance's documents refuse past their window. */
|
|
6126
|
+
onFormat2WriteRefused(listener) {
|
|
6127
|
+
this.format2WriteRefusedListeners.add(listener);
|
|
6128
|
+
return () => this.format2WriteRefusedListeners.delete(listener);
|
|
6129
|
+
}
|
|
6130
|
+
/**
|
|
6131
|
+
* Tell them, and swallow what they throw.
|
|
6132
|
+
*
|
|
6133
|
+
* A listener is application code, and a write's outcome is decided by the
|
|
6134
|
+
* window and not by whoever was listening: a handler that throws must
|
|
6135
|
+
* neither change what the write throws nor stop the next handler
|
|
6136
|
+
* (#3758, behavior 6).
|
|
6137
|
+
*
|
|
6138
|
+
* Over a SNAPSHOT of the set, so one report reaches each listener exactly
|
|
6139
|
+
* once however the handlers subscribe and unsubscribe while it is being
|
|
6140
|
+
* delivered. A `Set` visits an element re-added during its own iteration,
|
|
6141
|
+
* so a handler that resubscribes itself would otherwise hear the same
|
|
6142
|
+
* refusal again — and one that does so unconditionally would loop for ever,
|
|
6143
|
+
* never letting the write's rejection reach its caller (finding 3758-C02).
|
|
6144
|
+
*/
|
|
6145
|
+
reportFormat2WriteRefused(event) {
|
|
6146
|
+
for (const listener of [...this.format2WriteRefusedListeners]) {
|
|
6147
|
+
if (!this.format2WriteRefusedListeners.has(listener)) continue;
|
|
6148
|
+
try {
|
|
6149
|
+
listener(event);
|
|
6150
|
+
} catch {
|
|
6151
|
+
}
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
5526
6154
|
/** Drop the model→document defaults that point at a disconnected document. */
|
|
5527
6155
|
clearMappingsForDocId(docId) {
|
|
5528
6156
|
const toRemove = [];
|
|
@@ -5588,6 +6216,7 @@ var init_OrmContext = __esm({
|
|
|
5588
6216
|
this.globalDefaultDocId = void 0;
|
|
5589
6217
|
this.defaultDocChangedListeners.clear();
|
|
5590
6218
|
this.modelDocMappingChangedListeners.clear();
|
|
6219
|
+
this.format2WriteRefusedListeners.clear();
|
|
5591
6220
|
this.format2Documents.clear();
|
|
5592
6221
|
this.format2Transitions.clear();
|
|
5593
6222
|
this.documentIndexingWork.clear();
|
|
@@ -7125,6 +7754,41 @@ var init_BaseModel = __esm({
|
|
|
7125
7754
|
resolveOrmContext(this).getFormat2StoreAsync(docId)?.assertReadable();
|
|
7126
7755
|
}
|
|
7127
7756
|
}
|
|
7757
|
+
/**
|
|
7758
|
+
* The one gate every local write on a large document passes (#3758).
|
|
7759
|
+
*
|
|
7760
|
+
* Two doors reach it — `save()` and `delete()` — and both have to refuse
|
|
7761
|
+
* for the same reasons, report the same way and throw the same error. Two
|
|
7762
|
+
* copies of "report, log, then throw" is two chances for them to drift, so
|
|
7763
|
+
* the sequence is stated once, here, mirroring the single boundary Swift
|
|
7764
|
+
* keeps in `Format2WritePath`.
|
|
7765
|
+
*
|
|
7766
|
+
* Runs BEFORE anything is planned or committed, so a refused write leaves
|
|
7767
|
+
* no merged row and no pending op behind; the report reaches the listeners
|
|
7768
|
+
* synchronously, before the rejection reaches the caller.
|
|
7769
|
+
*/
|
|
7770
|
+
static assertFormat2Writable(docId, modelName, recordId, store) {
|
|
7771
|
+
store.assertReadable();
|
|
7772
|
+
try {
|
|
7773
|
+
assertWithinOfflineWindow(docId, store);
|
|
7774
|
+
} catch (error) {
|
|
7775
|
+
if (!(error instanceof DocumentOfflineWindowError)) throw error;
|
|
7776
|
+
Logger.warn(
|
|
7777
|
+
"[format2] write refused: past the offline window \u2014",
|
|
7778
|
+
docId,
|
|
7779
|
+
modelName,
|
|
7780
|
+
recordId,
|
|
7781
|
+
error.code
|
|
7782
|
+
);
|
|
7783
|
+
resolveOrmContext(this).reportFormat2WriteRefused({
|
|
7784
|
+
documentId: docId,
|
|
7785
|
+
model: modelName,
|
|
7786
|
+
recordId,
|
|
7787
|
+
error
|
|
7788
|
+
});
|
|
7789
|
+
throw error;
|
|
7790
|
+
}
|
|
7791
|
+
}
|
|
7128
7792
|
/**
|
|
7129
7793
|
* Build an instance already holding its merged-view row (#3429).
|
|
7130
7794
|
*
|
|
@@ -8923,8 +9587,12 @@ var init_BaseModel = __esm({
|
|
|
8923
9587
|
}
|
|
8924
9588
|
const format2Store = modelConstructor.getFormat2StoreAsync(targetDocId);
|
|
8925
9589
|
if (format2Store) {
|
|
8926
|
-
|
|
8927
|
-
|
|
9590
|
+
this.constructor.assertFormat2Writable(
|
|
9591
|
+
targetDocId,
|
|
9592
|
+
modelName,
|
|
9593
|
+
this.id,
|
|
9594
|
+
format2Store
|
|
9595
|
+
);
|
|
8928
9596
|
const target = await this.constructor.resolveFormat2Target(
|
|
8929
9597
|
targetDocId,
|
|
8930
9598
|
modelName,
|
|
@@ -9698,8 +10366,12 @@ var init_BaseModel = __esm({
|
|
|
9698
10366
|
sourceYMap = modelConstructor.documentYMaps.get(`${docId}_${modelName}`) || null;
|
|
9699
10367
|
const format2Store = modelConstructor.getFormat2StoreAsync(docId);
|
|
9700
10368
|
if (format2Store) {
|
|
9701
|
-
|
|
9702
|
-
|
|
10369
|
+
modelConstructor.assertFormat2Writable(
|
|
10370
|
+
docId,
|
|
10371
|
+
modelName,
|
|
10372
|
+
this.id,
|
|
10373
|
+
format2Store
|
|
10374
|
+
);
|
|
9703
10375
|
const target = await modelConstructor.resolveFormat2Target(
|
|
9704
10376
|
docId,
|
|
9705
10377
|
modelName,
|
|
@@ -9724,10 +10396,18 @@ var init_BaseModel = __esm({
|
|
|
9724
10396
|
}
|
|
9725
10397
|
const recordYMap = sourceYMap.get(this.id);
|
|
9726
10398
|
if (!recordYMap) {
|
|
9727
|
-
Logger.warn(
|
|
9728
|
-
`[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}. Cannot remove from unique constraint maps. Attempting main map deletion only.`
|
|
9729
|
-
);
|
|
9730
10399
|
sourceYDoc.transact(() => {
|
|
10400
|
+
const removed = removeUniqueIndexEntriesForRecordId(
|
|
10401
|
+
sourceYDoc,
|
|
10402
|
+
modelName,
|
|
10403
|
+
schema.resolvedUniqueConstraints.map((c) => c.name),
|
|
10404
|
+
this.id
|
|
10405
|
+
);
|
|
10406
|
+
if (removed > 0) {
|
|
10407
|
+
Logger.verbose(
|
|
10408
|
+
`[${modelName}] Delete: Item ${this.id} not found in YMap${docId ? ` for document ${docId}` : ""}; removed ${removed} unique-index entr${removed === 1 ? "y" : "ies"} still naming it.`
|
|
10409
|
+
);
|
|
10410
|
+
}
|
|
9731
10411
|
sourceYMap.delete(this.id);
|
|
9732
10412
|
}, `delete-ghost-record-${modelName}-${this.id}`);
|
|
9733
10413
|
return;
|
|
@@ -9747,6 +10427,7 @@ var init_BaseModel = __esm({
|
|
|
9747
10427
|
if (uniqueKey !== null) {
|
|
9748
10428
|
const constraintMapName = `_uniqueIdx_${modelName}_${constraint.name}`;
|
|
9749
10429
|
const constraintMap = sourceYDoc.getMap(constraintMapName);
|
|
10430
|
+
if (constraintMap.get(uniqueKey) !== this.id) continue;
|
|
9750
10431
|
Logger.verbose(
|
|
9751
10432
|
`[${modelName}] Delete: Removing unique key '${uniqueKey.substring(
|
|
9752
10433
|
0,
|
|
@@ -10016,13 +10697,17 @@ var init_BaseModel = __esm({
|
|
|
10016
10697
|
}
|
|
10017
10698
|
const isFirstPage = !options?.uniqueStartKey;
|
|
10018
10699
|
const direction = options?.direction || 1;
|
|
10700
|
+
const aliasedSortFields = translatedQuery.cursorSortValueFields ?? [];
|
|
10019
10701
|
const cursors = CursorManager.generateResultCursors(
|
|
10020
|
-
results,
|
|
10702
|
+
results.map((row) => cursorMintingRecord(row, aliasedSortFields)),
|
|
10021
10703
|
translatedQuery.sortFields,
|
|
10022
10704
|
direction,
|
|
10023
10705
|
hasMore,
|
|
10024
10706
|
isFirstPage
|
|
10025
10707
|
);
|
|
10708
|
+
results = results.map(
|
|
10709
|
+
(row) => stripCursorSortValues(row, aliasedSortFields)
|
|
10710
|
+
);
|
|
10026
10711
|
if (direction === -1) {
|
|
10027
10712
|
results.reverse();
|
|
10028
10713
|
}
|
|
@@ -10794,24 +11479,25 @@ var init_BaseModel = __esm({
|
|
|
10794
11479
|
constraint.name
|
|
10795
11480
|
);
|
|
10796
11481
|
const constraintMap2 = yDoc.getMap(constraintMapName2);
|
|
10797
|
-
const
|
|
11482
|
+
const documentYMapKey = `${docId}_${currentModelName}`;
|
|
11483
|
+
const documentYMap = modelConstructor.documentYMaps.get(documentYMapKey);
|
|
11484
|
+
const recordId2 = modelConstructor._readableUniqueOwner(
|
|
11485
|
+
documentYMap,
|
|
11486
|
+
constraintMap2,
|
|
11487
|
+
constraint,
|
|
11488
|
+
uniqueKeyString,
|
|
11489
|
+
currentModelName
|
|
11490
|
+
);
|
|
10798
11491
|
if (recordId2) {
|
|
10799
|
-
const
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
instance._metaPermissionHint = documentInfo.permissionHint;
|
|
10809
|
-
Logger.verbose(
|
|
10810
|
-
`[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
|
|
10811
|
-
);
|
|
10812
|
-
return instance;
|
|
10813
|
-
}
|
|
10814
|
-
}
|
|
11492
|
+
const instance = new modelConstructor({
|
|
11493
|
+
id: recordId2
|
|
11494
|
+
});
|
|
11495
|
+
instance._metaDocId = docId;
|
|
11496
|
+
instance._metaPermissionHint = documentInfo.permissionHint;
|
|
11497
|
+
Logger.verbose(
|
|
11498
|
+
`[${currentModelName}.findByUnique] Found record '${recordId2}' in document '${docId}'`
|
|
11499
|
+
);
|
|
11500
|
+
return instance;
|
|
10815
11501
|
}
|
|
10816
11502
|
}
|
|
10817
11503
|
const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
|
|
@@ -10842,7 +11528,13 @@ var init_BaseModel = __esm({
|
|
|
10842
11528
|
constraint.name
|
|
10843
11529
|
);
|
|
10844
11530
|
const constraintMap = legacyDocInfo.yDoc.getMap(constraintMapName);
|
|
10845
|
-
const recordId =
|
|
11531
|
+
const recordId = modelConstructor._readableUniqueOwner(
|
|
11532
|
+
legacyDocumentMap,
|
|
11533
|
+
constraintMap,
|
|
11534
|
+
constraint,
|
|
11535
|
+
uniqueKeyString,
|
|
11536
|
+
currentModelName
|
|
11537
|
+
);
|
|
10846
11538
|
if (!recordId) {
|
|
10847
11539
|
const fallbackRecord = await modelConstructor._resolveByUniqueFallback(
|
|
10848
11540
|
constraint,
|
|
@@ -10928,7 +11620,13 @@ var init_BaseModel = __esm({
|
|
|
10928
11620
|
const docInfo = connectedDocuments.get(docId);
|
|
10929
11621
|
if (docInfo) {
|
|
10930
11622
|
const constraintMap = docInfo.yDoc.getMap(constraintMapName);
|
|
10931
|
-
const recordId =
|
|
11623
|
+
const recordId = modelConstructor._readableUniqueOwner(
|
|
11624
|
+
modelConstructor.documentYMaps?.get(`${docId}_${currentModelName}`),
|
|
11625
|
+
constraintMap,
|
|
11626
|
+
constraint,
|
|
11627
|
+
uniqueKeyString,
|
|
11628
|
+
currentModelName
|
|
11629
|
+
);
|
|
10932
11630
|
if (recordId) {
|
|
10933
11631
|
existingRecord = await modelConstructor.find(
|
|
10934
11632
|
recordId
|
|
@@ -11394,6 +12092,7 @@ __export(browser_exports, {
|
|
|
11394
12092
|
completeOverlayEntries: () => completeOverlayEntries,
|
|
11395
12093
|
configuredOfflineWindowDays: () => configuredOfflineWindowDays,
|
|
11396
12094
|
createModelClass: () => createModelClass,
|
|
12095
|
+
decideDeferredReplay: () => decideDeferredReplay,
|
|
11397
12096
|
decodeFormat2StoreError: () => decodeFormat2StoreError,
|
|
11398
12097
|
defineModelSchema: () => defineModelSchema,
|
|
11399
12098
|
detectEnvironment: () => detectEnvironment,
|
|
@@ -11409,6 +12108,7 @@ __export(browser_exports, {
|
|
|
11409
12108
|
followEpoch: () => followEpoch,
|
|
11410
12109
|
format2ClientDDL: () => format2ClientDDL,
|
|
11411
12110
|
format2EpochDocName: () => format2EpochDocName,
|
|
12111
|
+
format2EpochDocPrefix: () => format2EpochDocPrefix,
|
|
11412
12112
|
format2StringSetMembers: () => format2StringSetMembers,
|
|
11413
12113
|
format2TableNames: () => format2TableNames,
|
|
11414
12114
|
generateULID: () => generateULID,
|
|
@@ -11548,11 +12248,35 @@ var DatabaseEngine = class {
|
|
|
11548
12248
|
*/
|
|
11549
12249
|
format2StoredEpoch;
|
|
11550
12250
|
/**
|
|
11551
|
-
*
|
|
12251
|
+
* Remove one large document's local data, and the store holding it when
|
|
12252
|
+
* nothing else is in there.
|
|
11552
12253
|
*
|
|
11553
|
-
* An engine whose
|
|
11554
|
-
*
|
|
11555
|
-
*
|
|
12254
|
+
* An engine whose store lives somewhere only it can reach — the browser's
|
|
12255
|
+
* worker-hosted pool — implements this; engines whose SQLite is synchronous
|
|
12256
|
+
* and local leave it undefined and the in-process path purges through
|
|
12257
|
+
* `createFormat2Sql()` instead. Either way the doors an app sees
|
|
12258
|
+
* (`documents.evict`, `closeDocument({ evictLocal })`,
|
|
12259
|
+
* `logout({ wipeLocal })`) remove the same things.
|
|
12260
|
+
*
|
|
12261
|
+
* `scope` is what the store is namespaced by beside the document, passed by
|
|
12262
|
+
* the caller rather than remembered here: a purge at logout is about the
|
|
12263
|
+
* user who is signing OUT, and an engine that outlives the sign-in would
|
|
12264
|
+
* otherwise resolve the wrong store.
|
|
12265
|
+
*/
|
|
12266
|
+
format2Purge;
|
|
12267
|
+
/**
|
|
12268
|
+
* Remove every large document's local data within one scope, except `keep`.
|
|
12269
|
+
*
|
|
12270
|
+
* Reaches documents this session never opened, and stores left behind before
|
|
12271
|
+
* a purge existed — neither of which has anything in memory naming it.
|
|
12272
|
+
*/
|
|
12273
|
+
format2PurgeAll;
|
|
12274
|
+
/**
|
|
12275
|
+
* The engine that holds `docIds`' derived query tables for `modelName`.
|
|
12276
|
+
*
|
|
12277
|
+
* An engine whose large documents' query tables live apart from the
|
|
12278
|
+
* ordinary documents' — the browser's worker-hosted engine — answers with
|
|
12279
|
+
* the engine to run the statement on, or refuses a scope that would need
|
|
11556
12280
|
* both. Engines with one database for everything leave it undefined and
|
|
11557
12281
|
* `BaseModel` uses the engine itself.
|
|
11558
12282
|
*/
|
|
@@ -12720,6 +13444,8 @@ var Format2NamespaceAttachment = class {
|
|
|
12720
13444
|
*/
|
|
12721
13445
|
hiding = false;
|
|
12722
13446
|
leaderRelease = null;
|
|
13447
|
+
/** Set by a `retire` that succeeded: the pool's VFS is paused (#3754). */
|
|
13448
|
+
hasRetired = false;
|
|
12723
13449
|
election = null;
|
|
12724
13450
|
electionQueued = false;
|
|
12725
13451
|
broker = null;
|
|
@@ -12783,6 +13509,39 @@ var Format2NamespaceAttachment = class {
|
|
|
12783
13509
|
holders(docId) {
|
|
12784
13510
|
return this.send(FORMAT2_WORKER_CHANNEL, { kind: "holders", docId });
|
|
12785
13511
|
}
|
|
13512
|
+
/**
|
|
13513
|
+
* Remove `docId`'s rows from the pool's store (#3754).
|
|
13514
|
+
*
|
|
13515
|
+
* Goes out on whatever this tab is attached to, leader or follower: the
|
|
13516
|
+
* command touches rows, not the pool, and the worker refuses it while any
|
|
13517
|
+
* session of the document is registered from any port.
|
|
13518
|
+
*/
|
|
13519
|
+
purge(docId, force) {
|
|
13520
|
+
return this.send(FORMAT2_WORKER_CHANNEL, { kind: "purge", docId, force });
|
|
13521
|
+
}
|
|
13522
|
+
/**
|
|
13523
|
+
* Ask the worker to give up the pool, so its directory may be removed
|
|
13524
|
+
* (#3754).
|
|
13525
|
+
*
|
|
13526
|
+
* Refused while any session on any document exists. On `retired: true` the
|
|
13527
|
+
* database is closed, the VFS paused and the pool lock released BEFORE the
|
|
13528
|
+
* reply, so the caller may remove the directory the moment it reads it — and
|
|
13529
|
+
* it still holds the leader lock while it does, which is what stops a
|
|
13530
|
+
* re-elected tab installing into a directory that is about to go.
|
|
13531
|
+
*/
|
|
13532
|
+
async retire() {
|
|
13533
|
+
const reply = await this.send(FORMAT2_WORKER_CHANNEL, { kind: "retire" });
|
|
13534
|
+
if (reply.retired) this.hasRetired = true;
|
|
13535
|
+
return reply;
|
|
13536
|
+
}
|
|
13537
|
+
/** Whether this attachment's worker has given up its pool (#3754). */
|
|
13538
|
+
get retired() {
|
|
13539
|
+
return this.hasRetired;
|
|
13540
|
+
}
|
|
13541
|
+
/** Whether this tab holds the namespace's leader lock right now (#3754). */
|
|
13542
|
+
get leads() {
|
|
13543
|
+
return this.attachment?.role === "leader" && (this.options.locks === null || this.leaderRelease !== null);
|
|
13544
|
+
}
|
|
12786
13545
|
release(docId, clientId, wipeIfLast) {
|
|
12787
13546
|
return this.send(FORMAT2_WORKER_CHANNEL, {
|
|
12788
13547
|
kind: "release",
|
|
@@ -13307,7 +14066,7 @@ var Format2NamespaceAttachment = class {
|
|
|
13307
14066
|
* lifecycle step-down above never consults it, because a frozen page would
|
|
13308
14067
|
* never reach the end of it.
|
|
13309
14068
|
*/
|
|
13310
|
-
async destroy() {
|
|
14069
|
+
async destroy(options = {}) {
|
|
13311
14070
|
if (this.destroyed) return;
|
|
13312
14071
|
const current = this.attachment;
|
|
13313
14072
|
const leaving = Array.from(this.sessions);
|
|
@@ -13324,7 +14083,9 @@ var Format2NamespaceAttachment = class {
|
|
|
13324
14083
|
this.attachment = null;
|
|
13325
14084
|
for (const session of leaving) session.detach();
|
|
13326
14085
|
if (current?.role === "leader") this.sayBye();
|
|
13327
|
-
if (current) {
|
|
14086
|
+
if (current && options.alreadyRetired) {
|
|
14087
|
+
current.handle.terminate();
|
|
14088
|
+
} else if (current) {
|
|
13328
14089
|
try {
|
|
13329
14090
|
await current.handle.close(this.options.closeBoundMs);
|
|
13330
14091
|
} catch (error) {
|
|
@@ -13527,6 +14288,42 @@ function observeFormat2Lifecycle(target, handlers) {
|
|
|
13527
14288
|
function format2ClientLockName(namespace, clientId) {
|
|
13528
14289
|
return `${namespace.lockName}:client:${encodePart(clientId)}`;
|
|
13529
14290
|
}
|
|
14291
|
+
function format2DocumentLockName(namespace, docId) {
|
|
14292
|
+
return `${namespace.lockName}:doc:${encodePart(docId)}`;
|
|
14293
|
+
}
|
|
14294
|
+
function format2PoolDirectory(poolName) {
|
|
14295
|
+
return `.${poolName}`;
|
|
14296
|
+
}
|
|
14297
|
+
function format2StoreNamespacePrefix(scope = {}) {
|
|
14298
|
+
const parts = [scope.server ?? "", scope.app ?? "", scope.user ?? ""];
|
|
14299
|
+
return parts.some((part) => part !== "") ? PREFIX + parts.map(encodePart).join("-") + "-" : PREFIX;
|
|
14300
|
+
}
|
|
14301
|
+
function format2DocumentIdFromPoolName(poolName, scope = {}) {
|
|
14302
|
+
const prefix = format2StoreNamespacePrefix(scope);
|
|
14303
|
+
if (!poolName.startsWith(prefix)) return null;
|
|
14304
|
+
const encoded = poolName.slice(prefix.length);
|
|
14305
|
+
if (encoded.length === 0 || encoded.includes("-")) return null;
|
|
14306
|
+
const bytes = [];
|
|
14307
|
+
for (let at = 0; at < encoded.length; ) {
|
|
14308
|
+
if (encoded[at] === "_") {
|
|
14309
|
+
const hex = encoded.slice(at + 1, at + 3);
|
|
14310
|
+
if (!/^[0-9a-f]{2}$/.test(hex)) return null;
|
|
14311
|
+
bytes.push(parseInt(hex, 16));
|
|
14312
|
+
at += 3;
|
|
14313
|
+
continue;
|
|
14314
|
+
}
|
|
14315
|
+
if (!/^[A-Za-z0-9]$/.test(encoded[at])) return null;
|
|
14316
|
+
bytes.push(encoded.charCodeAt(at));
|
|
14317
|
+
at += 1;
|
|
14318
|
+
}
|
|
14319
|
+
try {
|
|
14320
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
14321
|
+
new Uint8Array(bytes)
|
|
14322
|
+
);
|
|
14323
|
+
} catch {
|
|
14324
|
+
return null;
|
|
14325
|
+
}
|
|
14326
|
+
}
|
|
13530
14327
|
var PREFIX = "js-bao-format2-";
|
|
13531
14328
|
function encodePart(value) {
|
|
13532
14329
|
let out = "";
|
|
@@ -13593,6 +14390,8 @@ var OpfsEngine = class {
|
|
|
13593
14390
|
destroyed = false;
|
|
13594
14391
|
/** Fires on `destroy()`: no open in progress spawns another worker. */
|
|
13595
14392
|
teardown = new AbortController();
|
|
14393
|
+
/** The OPFS root, once something has asked for it (#3754). */
|
|
14394
|
+
resolvedRoot = null;
|
|
13596
14395
|
constructor(options = {}, internals = {}) {
|
|
13597
14396
|
this.internals = { ...options.internals ?? {}, ...internals };
|
|
13598
14397
|
this.mirror = new SqljsEngine({
|
|
@@ -13692,10 +14491,18 @@ var OpfsEngine = class {
|
|
|
13692
14491
|
*/
|
|
13693
14492
|
async createFormat2StoreHost(docId, options = {}) {
|
|
13694
14493
|
const namespace = format2StoreNamespace(docId, options.scope ?? {});
|
|
14494
|
+
const documentHeld = await this.holdShared(
|
|
14495
|
+
format2DocumentLockName(namespace, docId)
|
|
14496
|
+
);
|
|
14497
|
+
if (documentHeld) this.presence.add(documentHeld);
|
|
14498
|
+
const releaseDocument = () => {
|
|
14499
|
+
if (documentHeld && this.presence.delete(documentHeld)) documentHeld();
|
|
14500
|
+
};
|
|
13695
14501
|
let entry;
|
|
13696
14502
|
try {
|
|
13697
14503
|
entry = await this.ensureWorker(docId, options.scope);
|
|
13698
14504
|
} catch (error) {
|
|
14505
|
+
releaseDocument();
|
|
13699
14506
|
const reason = error?.message ?? String(error);
|
|
13700
14507
|
Logger.error(
|
|
13701
14508
|
`[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
|
|
@@ -13708,6 +14515,7 @@ var OpfsEngine = class {
|
|
|
13708
14515
|
host = entry.attachment.serveStore(docId, clientId);
|
|
13709
14516
|
await host.dispatch({ kind: "storedEpoch" });
|
|
13710
14517
|
} catch (error) {
|
|
14518
|
+
releaseDocument();
|
|
13711
14519
|
const reason = error?.message ?? String(error);
|
|
13712
14520
|
Logger.error(
|
|
13713
14521
|
`[OpfsEngine] ${docId}: the worker could not open its store: ${reason}`
|
|
@@ -13720,9 +14528,10 @@ var OpfsEngine = class {
|
|
|
13720
14528
|
else this.docs.set(docId, { entry, sessions: 1 });
|
|
13721
14529
|
const held = clientId ? this.holdPresence(format2ClientLockName(namespace, clientId)) : null;
|
|
13722
14530
|
if (held) this.presence.add(held);
|
|
13723
|
-
const releasePresence =
|
|
13724
|
-
if (this.presence.delete(held)) held();
|
|
13725
|
-
|
|
14531
|
+
const releasePresence = () => {
|
|
14532
|
+
if (held && this.presence.delete(held)) held();
|
|
14533
|
+
releaseDocument();
|
|
14534
|
+
};
|
|
13726
14535
|
const engine = this;
|
|
13727
14536
|
const closeOnce = async (closeOptions) => {
|
|
13728
14537
|
const routing2 = engine.docs.get(docId);
|
|
@@ -13730,7 +14539,7 @@ var OpfsEngine = class {
|
|
|
13730
14539
|
entry.docIds.delete(docId);
|
|
13731
14540
|
engine.docs.delete(docId);
|
|
13732
14541
|
}
|
|
13733
|
-
releasePresence
|
|
14542
|
+
releasePresence();
|
|
13734
14543
|
return host.close(closeOptions);
|
|
13735
14544
|
};
|
|
13736
14545
|
let closing = null;
|
|
@@ -13766,6 +14575,368 @@ var OpfsEngine = class {
|
|
|
13766
14575
|
return null;
|
|
13767
14576
|
}
|
|
13768
14577
|
}
|
|
14578
|
+
// ── Removing a document's store, and the pool holding it (#3754) ──
|
|
14579
|
+
/**
|
|
14580
|
+
* Remove `docId`'s local data, and its pool's directory once it holds
|
|
14581
|
+
* nothing else.
|
|
14582
|
+
*
|
|
14583
|
+
* The namespace is ALWAYS built from the `scope` the caller passed — there is
|
|
14584
|
+
* no cache of "which namespace did I last use for this document". This engine
|
|
14585
|
+
* outlives a sign-in, and the open path re-reads the scope every time, so a
|
|
14586
|
+
* remembered namespace would let user B's evict resolve user A's pool while
|
|
14587
|
+
* leaving B's alone (finding 3754-SO-06).
|
|
14588
|
+
*
|
|
14589
|
+
* Everything below happens under the document's presence lock, taken
|
|
14590
|
+
* EXCLUSIVE with `ifAvailable`: it never waits, so a document another tab has
|
|
14591
|
+
* open is refused rather than blocking, and holding it across the data purge,
|
|
14592
|
+
* the retire and the removal is what stops a tab opening into a half-purged
|
|
14593
|
+
* store (edge E14 — its shared request simply waits).
|
|
14594
|
+
*/
|
|
14595
|
+
async format2Purge(docId, options) {
|
|
14596
|
+
if (this.destroyed) {
|
|
14597
|
+
throw new DocumentClosedError(
|
|
14598
|
+
`[format2] The opfs engine was destroyed; '${docId}' cannot be purged.`
|
|
14599
|
+
);
|
|
14600
|
+
}
|
|
14601
|
+
await this.ensureRoot();
|
|
14602
|
+
const namespace = format2StoreNamespace(docId, options.scope ?? {});
|
|
14603
|
+
if (this.docs.has(docId)) {
|
|
14604
|
+
Logger.warn(
|
|
14605
|
+
`[OpfsEngine] ${docId}: purge refused: held (this tab has it open).`
|
|
14606
|
+
);
|
|
14607
|
+
return { purged: false, reason: "held" };
|
|
14608
|
+
}
|
|
14609
|
+
const taken = await this.withExclusive(
|
|
14610
|
+
format2DocumentLockName(namespace, docId),
|
|
14611
|
+
() => this.purgeUnderDocumentLock(
|
|
14612
|
+
docId,
|
|
14613
|
+
namespace,
|
|
14614
|
+
options.scope ?? {},
|
|
14615
|
+
options.force
|
|
14616
|
+
)
|
|
14617
|
+
);
|
|
14618
|
+
if (!taken.got) {
|
|
14619
|
+
Logger.warn(
|
|
14620
|
+
`[OpfsEngine] ${docId}: purge refused: held (another tab holds its presence lock, so it still has the document open).`
|
|
14621
|
+
);
|
|
14622
|
+
return { purged: false, reason: "held", presence: true };
|
|
14623
|
+
}
|
|
14624
|
+
return taken.value;
|
|
14625
|
+
}
|
|
14626
|
+
/**
|
|
14627
|
+
* Remove every large document of one scope except `keep` (#3754).
|
|
14628
|
+
*
|
|
14629
|
+
* Driven by the OPFS root's ENTRIES rather than by anything in memory, which
|
|
14630
|
+
* is the only thing that reaches a document this session never opened and a
|
|
14631
|
+
* pool left behind before a purge existed (finding R3). A scoped prefix ends
|
|
14632
|
+
* in the separator and no encoded part contains one, so it can match neither
|
|
14633
|
+
* another scope's pool nor an unscoped one (edge E4).
|
|
14634
|
+
*/
|
|
14635
|
+
async format2PurgeAll(options) {
|
|
14636
|
+
if (this.destroyed) {
|
|
14637
|
+
throw new DocumentClosedError(
|
|
14638
|
+
"[format2] The opfs engine was destroyed; its pools cannot be swept."
|
|
14639
|
+
);
|
|
14640
|
+
}
|
|
14641
|
+
await this.ensureRoot();
|
|
14642
|
+
const scope = options.scope ?? {};
|
|
14643
|
+
const prefix = format2PoolDirectory(format2StoreNamespacePrefix(scope));
|
|
14644
|
+
const keepDirectories = new Set(
|
|
14645
|
+
(options.keep ?? []).map(
|
|
14646
|
+
(id) => format2PoolDirectory(format2StoreNamespace(id, scope).poolName)
|
|
14647
|
+
)
|
|
14648
|
+
);
|
|
14649
|
+
const removed = [];
|
|
14650
|
+
const skipped = [];
|
|
14651
|
+
for (const directory of await this.listPoolDirectories(prefix)) {
|
|
14652
|
+
if (keepDirectories.has(directory)) {
|
|
14653
|
+
skipped.push({ pool: directory, reason: "keep" });
|
|
14654
|
+
continue;
|
|
14655
|
+
}
|
|
14656
|
+
const poolName = directory.slice(1);
|
|
14657
|
+
const docId = format2DocumentIdFromPoolName(poolName, scope);
|
|
14658
|
+
if (docId === null) {
|
|
14659
|
+
skipped.push({ pool: directory, reason: "unrecognized" });
|
|
14660
|
+
continue;
|
|
14661
|
+
}
|
|
14662
|
+
const result = await this.format2Purge(docId, {
|
|
14663
|
+
force: options.force,
|
|
14664
|
+
scope
|
|
14665
|
+
});
|
|
14666
|
+
if (result.purged && result.poolRemoved) {
|
|
14667
|
+
removed.push(directory);
|
|
14668
|
+
continue;
|
|
14669
|
+
}
|
|
14670
|
+
const reason = result.purged ? "kept-pool" : result.reason;
|
|
14671
|
+
skipped.push({ pool: directory, reason });
|
|
14672
|
+
Logger.warn(
|
|
14673
|
+
`[OpfsEngine] the sweep skipped the pool '${directory}': ${reason}.`
|
|
14674
|
+
);
|
|
14675
|
+
}
|
|
14676
|
+
Logger.info(
|
|
14677
|
+
`[OpfsEngine] large-document sweep removed ${removed.length} pool(s), skipped ${skipped.length}.`
|
|
14678
|
+
);
|
|
14679
|
+
return { removed, skipped };
|
|
14680
|
+
}
|
|
14681
|
+
/**
|
|
14682
|
+
* The purge itself, with this tab holding the document lock exclusively.
|
|
14683
|
+
*
|
|
14684
|
+
* Three cases, and what they have in common is that no removal is EVER
|
|
14685
|
+
* decided on this engine's own bookkeeping: `entry.docIds` and
|
|
14686
|
+
* `routing.sessions` say what THIS tab serves, while the worker's session map
|
|
14687
|
+
* says what every tab does (finding 3754-SO-03).
|
|
14688
|
+
*/
|
|
14689
|
+
async purgeUnderDocumentLock(docId, namespace, scope, force) {
|
|
14690
|
+
const existing = this.workers.get(namespace.poolName);
|
|
14691
|
+
const attached = existing && !existing.attachment.died ? existing : null;
|
|
14692
|
+
if (!attached) {
|
|
14693
|
+
return this.purgeWithoutAttachment(docId, namespace, scope, force);
|
|
14694
|
+
}
|
|
14695
|
+
return this.purgeThroughAttachment(docId, namespace, attached, force);
|
|
14696
|
+
}
|
|
14697
|
+
/** No attachment for the namespace: what is on disk decides what happens. */
|
|
14698
|
+
async purgeWithoutAttachment(docId, namespace, scope, force) {
|
|
14699
|
+
const directory = format2PoolDirectory(namespace.poolName);
|
|
14700
|
+
const root = this.opfsRoot();
|
|
14701
|
+
if (root) {
|
|
14702
|
+
const present = await this.directoryExists(root, directory);
|
|
14703
|
+
if (!present) {
|
|
14704
|
+
Logger.info(
|
|
14705
|
+
`[OpfsEngine] ${docId}: nothing stored locally (no pool directory).`
|
|
14706
|
+
);
|
|
14707
|
+
return { purged: true, nothingStored: true, poolRemoved: false };
|
|
14708
|
+
}
|
|
14709
|
+
if (force) {
|
|
14710
|
+
const under = await this.withExclusive(
|
|
14711
|
+
namespace.lockName,
|
|
14712
|
+
() => this.removePoolDirectory(namespace, docId, null)
|
|
14713
|
+
);
|
|
14714
|
+
if (under.got && under.value) {
|
|
14715
|
+
return { purged: true, poolRemoved: true, pendingOps: 0 };
|
|
14716
|
+
}
|
|
14717
|
+
if (under.got) {
|
|
14718
|
+
Logger.warn(
|
|
14719
|
+
`[OpfsEngine] ${docId}: its pool directory could not be removed directly, so its store is purged through a worker instead.`
|
|
14720
|
+
);
|
|
14721
|
+
}
|
|
14722
|
+
}
|
|
14723
|
+
}
|
|
14724
|
+
let entry;
|
|
14725
|
+
try {
|
|
14726
|
+
entry = await this.ensureWorker(docId, scope);
|
|
14727
|
+
} catch (error) {
|
|
14728
|
+
const reason = error?.message ?? String(error);
|
|
14729
|
+
Logger.warn(
|
|
14730
|
+
`[OpfsEngine] ${docId}: purge refused: unreachable \u2014 another tab leads its pool and no port was offered (${reason}).`
|
|
14731
|
+
);
|
|
14732
|
+
return { purged: false, reason: "unreachable" };
|
|
14733
|
+
}
|
|
14734
|
+
return this.purgeThroughAttachment(docId, namespace, entry, force);
|
|
14735
|
+
}
|
|
14736
|
+
/** An attachment for the namespace: the worker decides, then the leader acts. */
|
|
14737
|
+
async purgeThroughAttachment(docId, namespace, entry, force) {
|
|
14738
|
+
let reply;
|
|
14739
|
+
try {
|
|
14740
|
+
reply = await entry.attachment.purge(docId, force);
|
|
14741
|
+
} catch (error) {
|
|
14742
|
+
const reason = error?.message ?? String(error);
|
|
14743
|
+
Logger.warn(
|
|
14744
|
+
`[OpfsEngine] ${docId}: purge refused: unreachable (${reason}).`
|
|
14745
|
+
);
|
|
14746
|
+
return { purged: false, reason: "unreachable" };
|
|
14747
|
+
}
|
|
14748
|
+
if (!reply.purged) {
|
|
14749
|
+
if (reply.reason === "pending") {
|
|
14750
|
+
Logger.warn(
|
|
14751
|
+
`[OpfsEngine] ${docId}: purge refused: ${reply.pendingOps ?? 0} unacknowledged write(s); purge with force to drop them.`
|
|
14752
|
+
);
|
|
14753
|
+
return {
|
|
14754
|
+
purged: false,
|
|
14755
|
+
reason: "pending",
|
|
14756
|
+
pendingOps: reply.pendingOps ?? 0
|
|
14757
|
+
};
|
|
14758
|
+
}
|
|
14759
|
+
Logger.warn(
|
|
14760
|
+
`[OpfsEngine] ${docId}: purge refused: held by ${reply.holders ?? 0} session(s) on the pool's worker.`
|
|
14761
|
+
);
|
|
14762
|
+
return { purged: false, reason: "held", holders: reply.holders };
|
|
14763
|
+
}
|
|
14764
|
+
Logger.info(
|
|
14765
|
+
`[OpfsEngine] ${docId}: local store purged (${reply.pendingOps} unacknowledged write(s) dropped, ${reply.remainingDocuments} document(s) left in the pool).`
|
|
14766
|
+
);
|
|
14767
|
+
if (reply.remainingDocuments > 0) {
|
|
14768
|
+
Logger.debug(
|
|
14769
|
+
`[OpfsEngine] ${docId}: its pool still holds ${reply.remainingDocuments} document(s), so its directory stays.`
|
|
14770
|
+
);
|
|
14771
|
+
return { ...purgedReport(reply), poolRemoved: false };
|
|
14772
|
+
}
|
|
14773
|
+
if (!entry.attachment.leads) {
|
|
14774
|
+
Logger.debug(
|
|
14775
|
+
`[OpfsEngine] ${docId}: another tab leads its pool, so its directory is left to that tab.`
|
|
14776
|
+
);
|
|
14777
|
+
return { ...purgedReport(reply), poolRemoved: false };
|
|
14778
|
+
}
|
|
14779
|
+
let retired;
|
|
14780
|
+
try {
|
|
14781
|
+
retired = await entry.attachment.retire();
|
|
14782
|
+
} catch (error) {
|
|
14783
|
+
Logger.warn(
|
|
14784
|
+
`[OpfsEngine] ${docId}: its pool could not be retired (${error?.message ?? String(error)}), so its directory stays.`
|
|
14785
|
+
);
|
|
14786
|
+
return { ...purgedReport(reply), poolRemoved: false };
|
|
14787
|
+
}
|
|
14788
|
+
if (!retired.retired) {
|
|
14789
|
+
Logger.warn(
|
|
14790
|
+
`[OpfsEngine] ${docId}: its pool was not retired \u2014 ${retired.holders} session(s) on its worker still hold a document open, so its directory stays.`
|
|
14791
|
+
);
|
|
14792
|
+
return { ...purgedReport(reply), poolRemoved: false };
|
|
14793
|
+
}
|
|
14794
|
+
const poolRemoved = await this.removePoolDirectory(namespace, docId, entry);
|
|
14795
|
+
this.workers.delete(namespace.poolName);
|
|
14796
|
+
this.attachments.delete(entry.attachment);
|
|
14797
|
+
await entry.attachment.destroy({ alreadyRetired: true }).catch(() => {
|
|
14798
|
+
});
|
|
14799
|
+
return { ...purgedReport(reply), poolRemoved };
|
|
14800
|
+
}
|
|
14801
|
+
/**
|
|
14802
|
+
* Remove one pool's OPFS directory. The ONLY caller of `removeEntry`.
|
|
14803
|
+
*
|
|
14804
|
+
* Refuses unless this tab holds the namespace's leader lock and the
|
|
14805
|
+
* document's presence lock exclusively, and unless the namespace has no
|
|
14806
|
+
* attachment still serving it — a directory whose sync access handles are
|
|
14807
|
+
* still held cannot be removed anyway (`NoModificationAllowedError`,
|
|
14808
|
+
* measured), and one that could be would be taken from under a tab that is
|
|
14809
|
+
* reading it.
|
|
14810
|
+
*
|
|
14811
|
+
* Answers whether the directory went; a failure is a warn and a `false`, not
|
|
14812
|
+
* a throw, because the DATA is already gone by this point and a caller that
|
|
14813
|
+
* treated the whole purge as failed would report it wrongly.
|
|
14814
|
+
*/
|
|
14815
|
+
async removePoolDirectory(namespace, docId, entry) {
|
|
14816
|
+
const directory = format2PoolDirectory(namespace.poolName);
|
|
14817
|
+
if (this.destroyed) {
|
|
14818
|
+
Logger.warn(
|
|
14819
|
+
`[OpfsEngine] ${docId}: the engine was destroyed while its purge was in flight, so '${directory}' is left on disk.`
|
|
14820
|
+
);
|
|
14821
|
+
return false;
|
|
14822
|
+
}
|
|
14823
|
+
const root = this.opfsRoot();
|
|
14824
|
+
if (!root) {
|
|
14825
|
+
Logger.debug(
|
|
14826
|
+
`[OpfsEngine] ${docId}: no OPFS root is configured, so '${directory}' is left on disk; its rows are gone.`
|
|
14827
|
+
);
|
|
14828
|
+
return false;
|
|
14829
|
+
}
|
|
14830
|
+
if (entry && !entry.attachment.leads) {
|
|
14831
|
+
Logger.warn(
|
|
14832
|
+
`[OpfsEngine] ${docId}: this tab no longer leads its pool, so '${directory}' is left on disk; its rows are gone.`
|
|
14833
|
+
);
|
|
14834
|
+
return false;
|
|
14835
|
+
}
|
|
14836
|
+
if (entry && !entry.attachment.retired) {
|
|
14837
|
+
Logger.warn(
|
|
14838
|
+
`[OpfsEngine] ${docId}: its worker has not paused its VFS, so '${directory}' is left on disk.`
|
|
14839
|
+
);
|
|
14840
|
+
return false;
|
|
14841
|
+
}
|
|
14842
|
+
try {
|
|
14843
|
+
await root.removeEntry(directory, { recursive: true });
|
|
14844
|
+
Logger.info(
|
|
14845
|
+
`[OpfsEngine] ${docId}: removed its pool directory '${directory}'.`
|
|
14846
|
+
);
|
|
14847
|
+
return true;
|
|
14848
|
+
} catch (error) {
|
|
14849
|
+
Logger.warn(
|
|
14850
|
+
`[OpfsEngine] ${docId}: could not remove its pool directory '${directory}': ${error?.message ?? String(error)}`
|
|
14851
|
+
);
|
|
14852
|
+
return false;
|
|
14853
|
+
}
|
|
14854
|
+
}
|
|
14855
|
+
/** `navigator.storage.getDirectory()`'s handle, or `null` where there is none. */
|
|
14856
|
+
opfsRoot() {
|
|
14857
|
+
if (this.internals.opfsRoot !== void 0) return this.internals.opfsRoot;
|
|
14858
|
+
return this.resolvedRoot;
|
|
14859
|
+
}
|
|
14860
|
+
/**
|
|
14861
|
+
* Resolve `navigator.storage.getDirectory()` once, if this platform has it.
|
|
14862
|
+
*
|
|
14863
|
+
* Awaited at the top of every purge rather than in the constructor: an engine
|
|
14864
|
+
* is built before anybody has asked it for anything, and a rejected promise
|
|
14865
|
+
* kept on a field would be an unhandled rejection in every app that never
|
|
14866
|
+
* purges.
|
|
14867
|
+
*/
|
|
14868
|
+
async ensureRoot() {
|
|
14869
|
+
if (this.internals.opfsRoot !== void 0 || this.resolvedRoot) return;
|
|
14870
|
+
const storage = globalThis.navigator?.storage;
|
|
14871
|
+
if (!storage || typeof storage.getDirectory !== "function") return;
|
|
14872
|
+
try {
|
|
14873
|
+
this.resolvedRoot = await storage.getDirectory();
|
|
14874
|
+
} catch (error) {
|
|
14875
|
+
Logger.debug(
|
|
14876
|
+
`[OpfsEngine] the OPFS root could not be opened: ${error?.message ?? String(error)}`
|
|
14877
|
+
);
|
|
14878
|
+
}
|
|
14879
|
+
}
|
|
14880
|
+
/** Whether `name` is a directory of the root, without creating it. */
|
|
14881
|
+
async directoryExists(root, name) {
|
|
14882
|
+
try {
|
|
14883
|
+
await root.getDirectoryHandle(name, { create: false });
|
|
14884
|
+
return true;
|
|
14885
|
+
} catch {
|
|
14886
|
+
return false;
|
|
14887
|
+
}
|
|
14888
|
+
}
|
|
14889
|
+
/** Every directory of the root beginning with `prefix`. */
|
|
14890
|
+
async listPoolDirectories(prefix) {
|
|
14891
|
+
const root = this.opfsRoot();
|
|
14892
|
+
if (!root) {
|
|
14893
|
+
Logger.debug(
|
|
14894
|
+
"[OpfsEngine] no OPFS root is configured, so no pool directory can be listed or removed."
|
|
14895
|
+
);
|
|
14896
|
+
return [];
|
|
14897
|
+
}
|
|
14898
|
+
const found = [];
|
|
14899
|
+
try {
|
|
14900
|
+
for await (const name of root.keys()) {
|
|
14901
|
+
if (name.startsWith(prefix)) found.push(name);
|
|
14902
|
+
}
|
|
14903
|
+
} catch (error) {
|
|
14904
|
+
Logger.warn(
|
|
14905
|
+
`[OpfsEngine] the OPFS root could not be listed: ${error?.message ?? String(error)}`
|
|
14906
|
+
);
|
|
14907
|
+
}
|
|
14908
|
+
return found;
|
|
14909
|
+
}
|
|
14910
|
+
/**
|
|
14911
|
+
* Hold `name` exclusively for the length of `run`, or say it could not be.
|
|
14912
|
+
*
|
|
14913
|
+
* `ifAvailable`, so it never waits: a purge that queued behind a tab with the
|
|
14914
|
+
* document open would sit there for as long as that tab lives.
|
|
14915
|
+
*/
|
|
14916
|
+
async withExclusive(name, run2) {
|
|
14917
|
+
const locks = this.locks();
|
|
14918
|
+
if (!locks) return { got: true, value: await run2() };
|
|
14919
|
+
let outcome = { got: false };
|
|
14920
|
+
let failure = null;
|
|
14921
|
+
await Promise.resolve(
|
|
14922
|
+
locks.request(name, { mode: "exclusive", ifAvailable: true }, async (lock) => {
|
|
14923
|
+
if (lock === null) return void 0;
|
|
14924
|
+
try {
|
|
14925
|
+
outcome = { got: true, value: await run2() };
|
|
14926
|
+
} catch (error) {
|
|
14927
|
+
outcome = { got: true };
|
|
14928
|
+
failure = error;
|
|
14929
|
+
}
|
|
14930
|
+
return void 0;
|
|
14931
|
+
})
|
|
14932
|
+
).catch((error) => {
|
|
14933
|
+
Logger.warn(
|
|
14934
|
+
`[OpfsEngine] the lock '${name}' could not be taken: ${error?.message ?? String(error)}`
|
|
14935
|
+
);
|
|
14936
|
+
});
|
|
14937
|
+
if (failure) throw failure;
|
|
14938
|
+
return outcome;
|
|
14939
|
+
}
|
|
13769
14940
|
/** `navigator.locks`, unless a test handed one in. `null` means none. */
|
|
13770
14941
|
locks() {
|
|
13771
14942
|
if (this.internals.locks !== void 0) return this.internals.locks;
|
|
@@ -13786,7 +14957,7 @@ var OpfsEngine = class {
|
|
|
13786
14957
|
* queued is withdrawn. A presence lock left held says a client is still here
|
|
13787
14958
|
* when its writes are sitting in the log waiting to be adopted.
|
|
13788
14959
|
*/
|
|
13789
|
-
holdPresence(name) {
|
|
14960
|
+
holdPresence(name, mode = "exclusive") {
|
|
13790
14961
|
const locks = this.locks();
|
|
13791
14962
|
if (!locks) return null;
|
|
13792
14963
|
let release = null;
|
|
@@ -13795,7 +14966,7 @@ var OpfsEngine = class {
|
|
|
13795
14966
|
void Promise.resolve(
|
|
13796
14967
|
locks.request(
|
|
13797
14968
|
name,
|
|
13798
|
-
{ mode
|
|
14969
|
+
{ mode, ...withdraw ? { signal: withdraw.signal } : {} },
|
|
13799
14970
|
() => new Promise((resolve) => {
|
|
13800
14971
|
if (done) resolve();
|
|
13801
14972
|
else release = resolve;
|
|
@@ -13809,6 +14980,35 @@ var OpfsEngine = class {
|
|
|
13809
14980
|
withdraw?.abort();
|
|
13810
14981
|
};
|
|
13811
14982
|
}
|
|
14983
|
+
/**
|
|
14984
|
+
* Hold `name` SHARED, waiting for the grant, and answer the release (#3754).
|
|
14985
|
+
*
|
|
14986
|
+
* Unlike {@link holdPresence}, which fires the request off and returns at
|
|
14987
|
+
* once, this waits: the caller's next act is to open a pool, and a purge that
|
|
14988
|
+
* holds this name exclusively is in the middle of emptying that pool. Waiting
|
|
14989
|
+
* here is what turns a race into an ordering.
|
|
14990
|
+
*/
|
|
14991
|
+
async holdShared(name) {
|
|
14992
|
+
const locks = this.locks();
|
|
14993
|
+
if (!locks) return null;
|
|
14994
|
+
let release = null;
|
|
14995
|
+
let done = false;
|
|
14996
|
+
await new Promise((granted) => {
|
|
14997
|
+
void Promise.resolve(
|
|
14998
|
+
locks.request(name, { mode: "shared" }, () => {
|
|
14999
|
+
granted();
|
|
15000
|
+
return new Promise((resolve) => {
|
|
15001
|
+
if (done) resolve();
|
|
15002
|
+
else release = resolve;
|
|
15003
|
+
});
|
|
15004
|
+
})
|
|
15005
|
+
).catch(() => granted());
|
|
15006
|
+
});
|
|
15007
|
+
return () => {
|
|
15008
|
+
done = true;
|
|
15009
|
+
release?.();
|
|
15010
|
+
};
|
|
15011
|
+
}
|
|
13812
15012
|
/** Whether somebody holds `name` right now. Never waits. */
|
|
13813
15013
|
async probeLock(name) {
|
|
13814
15014
|
const locks = this.locks();
|
|
@@ -13980,6 +15180,14 @@ var OpfsEngine = class {
|
|
|
13980
15180
|
});
|
|
13981
15181
|
}
|
|
13982
15182
|
};
|
|
15183
|
+
function purgedReport(reply) {
|
|
15184
|
+
return {
|
|
15185
|
+
purged: true,
|
|
15186
|
+
...reply.nothingStored ? { nothingStored: true } : {},
|
|
15187
|
+
pendingOps: reply.pendingOps,
|
|
15188
|
+
remainingDocuments: reply.remainingDocuments
|
|
15189
|
+
};
|
|
15190
|
+
}
|
|
13983
15191
|
function withinBound2(promise, boundMs) {
|
|
13984
15192
|
let timer;
|
|
13985
15193
|
const bound = new Promise((resolve) => {
|
|
@@ -14408,6 +15616,22 @@ function sameOverlayValue(a, b) {
|
|
|
14408
15616
|
if (a === null || a === void 0) return b === null || b === void 0;
|
|
14409
15617
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
14410
15618
|
}
|
|
15619
|
+
function decideDeferredReplay(note, epoch, ackedSeq) {
|
|
15620
|
+
if (!note || note.kind !== "ordinary") return { kind: "none" };
|
|
15621
|
+
let resolved = note;
|
|
15622
|
+
let clamped = false;
|
|
15623
|
+
if (note.toEpoch > epoch) {
|
|
15624
|
+
if (note.fromEpoch >= epoch) {
|
|
15625
|
+
return { kind: "clear", reason: "never-completed", note };
|
|
15626
|
+
}
|
|
15627
|
+
resolved = { ...note, toEpoch: epoch };
|
|
15628
|
+
clamped = true;
|
|
15629
|
+
}
|
|
15630
|
+
if (resolved.throughSeq <= ackedSeq) {
|
|
15631
|
+
return { kind: "clear", reason: "acknowledged", note: resolved };
|
|
15632
|
+
}
|
|
15633
|
+
return { kind: "honour", note: resolved, clamped };
|
|
15634
|
+
}
|
|
14411
15635
|
function restorePendingOverlay(doc, store, verdicts, options = {}) {
|
|
14412
15636
|
const result = {
|
|
14413
15637
|
restored: [],
|
|
@@ -14415,9 +15639,10 @@ function restorePendingOverlay(doc, store, verdicts, options = {}) {
|
|
|
14415
15639
|
deferred: []
|
|
14416
15640
|
};
|
|
14417
15641
|
const defer = options.deferAtOrBelowEpoch ?? 0;
|
|
15642
|
+
const deferThrough = options.deferThroughSeq ?? 0;
|
|
14418
15643
|
const missing = [];
|
|
14419
15644
|
for (const op of store.pendingOps()) {
|
|
14420
|
-
if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer) {
|
|
15645
|
+
if (defer > 0 && op.baseEpoch > 0 && op.baseEpoch <= defer || deferThrough > 0 && op.seq <= deferThrough) {
|
|
14421
15646
|
result.deferred.push(op.seq);
|
|
14422
15647
|
continue;
|
|
14423
15648
|
}
|
|
@@ -14451,17 +15676,71 @@ function statePendingOps(doc, ops, options = {}) {
|
|
|
14451
15676
|
(entry) => `${entry.model} ${entry.recordId}`
|
|
14452
15677
|
)
|
|
14453
15678
|
);
|
|
14454
|
-
const
|
|
14455
|
-
|
|
14456
|
-
);
|
|
15679
|
+
const owned = /* @__PURE__ */ new Map();
|
|
15680
|
+
const laterKeys = /* @__PURE__ */ new Map();
|
|
15681
|
+
const lifecycle = /* @__PURE__ */ new Map();
|
|
15682
|
+
for (const op of [...options.later ?? []].sort((a, b) => a.seq - b.seq)) {
|
|
15683
|
+
const key = `${op.model} ${op.recordId}`;
|
|
15684
|
+
let fields = owned.get(key);
|
|
15685
|
+
if (!fields) owned.set(key, fields = /* @__PURE__ */ new Set());
|
|
15686
|
+
for (const field of op.fields) fields.add(field);
|
|
15687
|
+
let keys = laterKeys.get(key);
|
|
15688
|
+
if (!keys) laterKeys.set(key, keys = /* @__PURE__ */ new Set());
|
|
15689
|
+
for (const [written] of op.mutation ? encodeOverlayMutation(op.mutation) : []) {
|
|
15690
|
+
keys.add(written);
|
|
15691
|
+
}
|
|
15692
|
+
lifecycle.set(key, op.op);
|
|
15693
|
+
}
|
|
15694
|
+
const stated = [];
|
|
15695
|
+
for (const op of [...ops].sort((a, b) => a.seq - b.seq)) {
|
|
15696
|
+
if (op.mutation === null) continue;
|
|
15697
|
+
const key = `${op.model} ${op.recordId}`;
|
|
15698
|
+
const now = lifecycle.get(key);
|
|
15699
|
+
if (op.op === "delete") {
|
|
15700
|
+
if (suppressed.has(key) || now !== void 0 && now !== "delete") {
|
|
15701
|
+
continue;
|
|
15702
|
+
}
|
|
15703
|
+
stated.push({ op, mutation: op.mutation });
|
|
15704
|
+
continue;
|
|
15705
|
+
}
|
|
15706
|
+
if (now === "delete") {
|
|
15707
|
+
continue;
|
|
15708
|
+
}
|
|
15709
|
+
const later = owned.get(key);
|
|
15710
|
+
if (later === void 0) {
|
|
15711
|
+
stated.push({ op, mutation: op.mutation });
|
|
15712
|
+
continue;
|
|
15713
|
+
}
|
|
15714
|
+
const narrowed = withoutFields(op.mutation, later);
|
|
15715
|
+
if (narrowed === null) continue;
|
|
15716
|
+
stated.push({ op, mutation: narrowed, keep: laterKeys.get(key) });
|
|
15717
|
+
}
|
|
14457
15718
|
if (stated.length === 0) return 0;
|
|
14458
15719
|
doc.transact(() => {
|
|
14459
|
-
for (const op of stated) {
|
|
14460
|
-
applyOverlayMutation(doc.getMap(op.model),
|
|
15720
|
+
for (const { op, mutation, keep } of stated) {
|
|
15721
|
+
applyOverlayMutation(doc.getMap(op.model), mutation, { keep });
|
|
14461
15722
|
}
|
|
14462
15723
|
});
|
|
14463
15724
|
return stated.length;
|
|
14464
15725
|
}
|
|
15726
|
+
function withoutFields(mutation, owned) {
|
|
15727
|
+
const fields = Object.fromEntries(
|
|
15728
|
+
Object.entries(mutation.fields ?? {}).filter(([field]) => !owned.has(field))
|
|
15729
|
+
);
|
|
15730
|
+
const deltas = Object.fromEntries(
|
|
15731
|
+
Object.entries(mutation.stringSetDeltas ?? {}).filter(
|
|
15732
|
+
([field]) => !owned.has(field)
|
|
15733
|
+
)
|
|
15734
|
+
);
|
|
15735
|
+
if (mutation.kind !== "create" && Object.keys(fields).length === 0 && Object.keys(deltas).length === 0) {
|
|
15736
|
+
return null;
|
|
15737
|
+
}
|
|
15738
|
+
return {
|
|
15739
|
+
...mutation,
|
|
15740
|
+
...mutation.fields === void 0 ? {} : { fields },
|
|
15741
|
+
...mutation.stringSetDeltas === void 0 ? {} : { stringSetDeltas: deltas }
|
|
15742
|
+
};
|
|
15743
|
+
}
|
|
14465
15744
|
|
|
14466
15745
|
// src/models/format2SnapshotLoader.ts
|
|
14467
15746
|
init_snapshotChunks();
|
|
@@ -14775,17 +16054,21 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
|
|
|
14775
16054
|
const crossed = store.discontinuityEpochs();
|
|
14776
16055
|
const boundary = crossed.length > 0 ? crossed[crossed.length - 1] : 0;
|
|
14777
16056
|
const deferAtOrBelowEpoch = boundary > 0 && store.epoch() > boundary ? boundary : 0;
|
|
14778
|
-
const
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
|
|
14782
|
-
|
|
14783
|
-
)
|
|
14784
|
-
if (restored.deferred.length > 0) {
|
|
16057
|
+
const owedJudgement = await this.settleDeferredReplay(docId, store);
|
|
16058
|
+
const restored = restorePendingOverlay(yDoc, store, verdicts, {
|
|
16059
|
+
...deferAtOrBelowEpoch > 0 ? { deferAtOrBelowEpoch } : {},
|
|
16060
|
+
...owedJudgement ? { deferThroughSeq: owedJudgement.throughSeq } : {}
|
|
16061
|
+
});
|
|
16062
|
+
if (restored.deferred.length > 0 && deferAtOrBelowEpoch > 0) {
|
|
14785
16063
|
Logger.warn(
|
|
14786
16064
|
`[DocumentManager] ${docId} is holding ${restored.deferred.length} unacknowledged write(s) off its overlay until it has converged on the bulk load at epoch ${deferAtOrBelowEpoch}.`
|
|
14787
16065
|
);
|
|
14788
16066
|
}
|
|
16067
|
+
if (restored.deferred.length > 0 && owedJudgement) {
|
|
16068
|
+
Logger.warn(
|
|
16069
|
+
`[format2] ${docId} is holding ${restored.deferred.length} unacknowledged write(s) off its overlay until the deferred judgement of epochs ${owedJudgement.fromEpoch}..${owedJudgement.toEpoch} has run.`
|
|
16070
|
+
);
|
|
16071
|
+
}
|
|
14789
16072
|
const adoptedWasRestored = verdicts !== void 0 && restored.restored.some((seq) => verdicts.has(seq));
|
|
14790
16073
|
if (adoptedWasRestored) {
|
|
14791
16074
|
foldFormat2Overlay(
|
|
@@ -14809,6 +16092,47 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
|
|
|
14809
16092
|
store.onReattached(() => this.revalidateQueryTables(docId, store))
|
|
14810
16093
|
);
|
|
14811
16094
|
}
|
|
16095
|
+
/**
|
|
16096
|
+
* Settle what a deferred judgement means for THIS bind (#3755).
|
|
16097
|
+
*
|
|
16098
|
+
* Returns the note whose covered writes must stay off the overlay, or `null`
|
|
16099
|
+
* when there is nothing to honour. The decision is
|
|
16100
|
+
* {@link decideDeferredReplay}'s, so the bind and the resumed judgement read
|
|
16101
|
+
* one note the same way.
|
|
16102
|
+
*
|
|
16103
|
+
* The clamp is deliberately NOT written back: `noteDeferredReplay` widens,
|
|
16104
|
+
* so it could not narrow a target, and the decision is a pure function of the
|
|
16105
|
+
* row and the store's epoch — every later reader reaches the same answer. A
|
|
16106
|
+
* note that is CLEARED is cleared durably, because that is a fact about the
|
|
16107
|
+
* debt rather than a reading of it.
|
|
16108
|
+
*/
|
|
16109
|
+
async settleDeferredReplay(docId, store) {
|
|
16110
|
+
const decision = decideDeferredReplay(
|
|
16111
|
+
store.deferredReplay(),
|
|
16112
|
+
store.epoch(),
|
|
16113
|
+
store.ackedSeq()
|
|
16114
|
+
);
|
|
16115
|
+
if (decision.kind === "none") return null;
|
|
16116
|
+
if (decision.kind === "clear") {
|
|
16117
|
+
if (decision.reason === "acknowledged") {
|
|
16118
|
+
Logger.debug(
|
|
16119
|
+
`[format2] ${docId}: the deferred judgement of epochs ${decision.note.fromEpoch}..${decision.note.toEpoch} is settled: every covered write was acknowledged through sequence ${decision.note.throughSeq}.`
|
|
16120
|
+
);
|
|
16121
|
+
} else {
|
|
16122
|
+
Logger.warn(
|
|
16123
|
+
`[format2] ${docId}: a deferred-replay note names epoch ${decision.note.toEpoch}, but this store is on ${store.epoch()} and nothing was deferred below it; the move never completed, restoring as usual.`
|
|
16124
|
+
);
|
|
16125
|
+
}
|
|
16126
|
+
await store.clearDeferredReplay();
|
|
16127
|
+
return null;
|
|
16128
|
+
}
|
|
16129
|
+
if (decision.clamped) {
|
|
16130
|
+
Logger.debug(
|
|
16131
|
+
`[format2] ${docId}: a later deferred move was interrupted, so the judgement this bind owes is clamped to the epoch this store reached: ${store.deferredReplay()?.fromEpoch}..${store.deferredReplay()?.toEpoch} \u2192 ${decision.note.fromEpoch}..${decision.note.toEpoch}.`
|
|
16132
|
+
);
|
|
16133
|
+
}
|
|
16134
|
+
return decision.note;
|
|
16135
|
+
}
|
|
14812
16136
|
/**
|
|
14813
16137
|
* Rebuild whatever the query tables lost while this tab was off the worker
|
|
14814
16138
|
* (#3431, finding 3431-R12).
|
|
@@ -15452,6 +16776,8 @@ function withinBound3(promise, boundMs) {
|
|
|
15452
16776
|
|
|
15453
16777
|
// src/models/format2Sync.ts
|
|
15454
16778
|
init_OrmContext();
|
|
16779
|
+
init_format2RecordStore();
|
|
16780
|
+
init_logger();
|
|
15455
16781
|
function format2StoredEpoch(engine, docId, ctx) {
|
|
15456
16782
|
if (!docId) return null;
|
|
15457
16783
|
const bound = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
@@ -15488,6 +16814,82 @@ async function format2StoredEpochAsync(engine, docId, ctx, scope) {
|
|
|
15488
16814
|
}
|
|
15489
16815
|
return format2StoredEpoch(engine, docId, ctx);
|
|
15490
16816
|
}
|
|
16817
|
+
async function format2Purge(engine, docId, options, ctx) {
|
|
16818
|
+
if (!docId) return { purged: false, reason: "held" };
|
|
16819
|
+
const bound = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
16820
|
+
if (bound) {
|
|
16821
|
+
Logger.warn(
|
|
16822
|
+
`[format2] ${docId}: its local store is open in this instance, so it was not purged; close the document first.`
|
|
16823
|
+
);
|
|
16824
|
+
return { purged: false, reason: "held" };
|
|
16825
|
+
}
|
|
16826
|
+
const force = options.force === true;
|
|
16827
|
+
if (engine?.format2Purge) {
|
|
16828
|
+
return engine.format2Purge(docId, { force, scope: options.scope });
|
|
16829
|
+
}
|
|
16830
|
+
const sql = engine?.createFormat2Sql?.();
|
|
16831
|
+
if (!sql) {
|
|
16832
|
+
return { purged: true, nothingStored: true, pendingOps: 0, remainingDocuments: 0 };
|
|
16833
|
+
}
|
|
16834
|
+
return purgeThroughStore(sql, docId, force);
|
|
16835
|
+
}
|
|
16836
|
+
function purgeThroughStore(sql, docId, force) {
|
|
16837
|
+
return sql.transaction(() => {
|
|
16838
|
+
const pendingOps = Format2RecordStore.pendingOpCount(sql, docId);
|
|
16839
|
+
if (!force && pendingOps > 0) {
|
|
16840
|
+
Logger.warn(
|
|
16841
|
+
`[format2] ${docId}: not purged \u2014 its local store holds ${pendingOps} unacknowledged write(s). Purge with force to drop them.`
|
|
16842
|
+
);
|
|
16843
|
+
return { purged: false, reason: "pending", pendingOps };
|
|
16844
|
+
}
|
|
16845
|
+
const report = Format2RecordStore.purge(sql, docId);
|
|
16846
|
+
Logger.info(
|
|
16847
|
+
`[format2] ${docId}: local store purged (${report.pendingOps} unacknowledged write(s) dropped, ${report.remainingDocuments} document(s) left in the store).`
|
|
16848
|
+
);
|
|
16849
|
+
return {
|
|
16850
|
+
purged: true,
|
|
16851
|
+
...report.nothingStored ? { nothingStored: true } : {},
|
|
16852
|
+
pendingOps: report.pendingOps,
|
|
16853
|
+
remainingDocuments: report.remainingDocuments
|
|
16854
|
+
};
|
|
16855
|
+
});
|
|
16856
|
+
}
|
|
16857
|
+
async function format2PurgeAll(engine, options, ctx) {
|
|
16858
|
+
const force = options.force === true;
|
|
16859
|
+
if (engine?.format2PurgeAll) {
|
|
16860
|
+
return engine.format2PurgeAll({
|
|
16861
|
+
...options.keep ? { keep: options.keep } : {},
|
|
16862
|
+
force,
|
|
16863
|
+
scope: options.scope
|
|
16864
|
+
});
|
|
16865
|
+
}
|
|
16866
|
+
const sql = engine?.createFormat2Sql?.();
|
|
16867
|
+
if (!sql) return { removed: [], skipped: [], purged: [] };
|
|
16868
|
+
const context = ctx ?? getDefaultOrmContext();
|
|
16869
|
+
const keep = new Set(options.keep ?? []);
|
|
16870
|
+
const purged = [];
|
|
16871
|
+
const skipped = [];
|
|
16872
|
+
for (const docId of Format2RecordStore.storedDocuments(sql)) {
|
|
16873
|
+
if (keep.has(docId)) {
|
|
16874
|
+
skipped.push({ pool: docId, reason: "keep" });
|
|
16875
|
+
continue;
|
|
16876
|
+
}
|
|
16877
|
+
if (context.getFormat2StoreAsync(docId)) {
|
|
16878
|
+
Logger.warn(
|
|
16879
|
+
`[format2] ${docId}: skipped by the local-store sweep \u2014 it is open in this instance.`
|
|
16880
|
+
);
|
|
16881
|
+
skipped.push({ pool: docId, reason: "held" });
|
|
16882
|
+
continue;
|
|
16883
|
+
}
|
|
16884
|
+
const result = purgeThroughStore(sql, docId, force);
|
|
16885
|
+
if (result.purged) purged.push(docId);
|
|
16886
|
+
else skipped.push({ pool: docId, reason: result.reason });
|
|
16887
|
+
}
|
|
16888
|
+
Logger.info(
|
|
16889
|
+
`[format2] local-store sweep purged ${purged.length} document(s), skipped ${skipped.length}.`
|
|
16890
|
+
);
|
|
16891
|
+
return { removed: [], skipped, purged };
|
|
16892
|
+
}
|
|
15491
16893
|
function format2SyncMarks(docId, ctx) {
|
|
15492
16894
|
const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
15493
16895
|
if (!store) return null;
|
|
@@ -15497,7 +16899,8 @@ function format2SyncMarks(docId, ctx) {
|
|
|
15497
16899
|
epoch: store.epoch(),
|
|
15498
16900
|
clockOffsetKnown: store.clockOffsetKnown(),
|
|
15499
16901
|
discontinuityEpochs: store.discontinuityEpochs(),
|
|
15500
|
-
baseComplete: store.baseComplete()
|
|
16902
|
+
baseComplete: store.baseComplete(),
|
|
16903
|
+
deferredReplay: store.deferredReplay()
|
|
15501
16904
|
};
|
|
15502
16905
|
}
|
|
15503
16906
|
function format2NoteEpoch(docId, epoch, ctx) {
|
|
@@ -15509,11 +16912,21 @@ function format2NoteEpoch(docId, epoch, ctx) {
|
|
|
15509
16912
|
function format2PendingOps(docId, ctx) {
|
|
15510
16913
|
return (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId)?.pendingOps() ?? [];
|
|
15511
16914
|
}
|
|
15512
|
-
function format2SetEpoch(docId, epoch, ctx) {
|
|
16915
|
+
function format2SetEpoch(docId, epoch, ctx, deferredReplay) {
|
|
15513
16916
|
const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
15514
16917
|
if (!store) return;
|
|
15515
16918
|
if (!Number.isInteger(epoch) || epoch <= 0) return;
|
|
15516
|
-
store.setEpoch(epoch);
|
|
16919
|
+
store.setEpoch(epoch, deferredReplay ? { deferredReplay } : {});
|
|
16920
|
+
}
|
|
16921
|
+
async function format2NoteDeferredReplay(docId, note, ctx) {
|
|
16922
|
+
const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
16923
|
+
if (!store) return;
|
|
16924
|
+
await store.noteDeferredReplay(note);
|
|
16925
|
+
}
|
|
16926
|
+
async function format2ClearDeferredReplay(docId, ctx) {
|
|
16927
|
+
const store = (ctx ?? getDefaultOrmContext()).getFormat2StoreAsync(docId);
|
|
16928
|
+
if (!store) return;
|
|
16929
|
+
await store.clearDeferredReplay();
|
|
15517
16930
|
}
|
|
15518
16931
|
function format2HoldWrites(docId, run2, ctx) {
|
|
15519
16932
|
return (ctx ?? getDefaultOrmContext()).holdFormat2Writes(docId, run2);
|
|
@@ -16088,17 +17501,42 @@ async function initJsBaoCore(options, getEngine) {
|
|
|
16088
17501
|
ormContext,
|
|
16089
17502
|
options.largeDocumentStoreScope?.() ?? void 0
|
|
16090
17503
|
),
|
|
17504
|
+
// #3754 — the three doors that remove a large document's local data.
|
|
17505
|
+
// The scope defaults to what an open would use and is otherwise the
|
|
17506
|
+
// caller's: at logout it names the user who is signing OUT, which this
|
|
17507
|
+
// engine no longer has any way to work out for itself.
|
|
17508
|
+
format2Purge: (docId, purgeOptions) => format2Purge(
|
|
17509
|
+
dbEngine,
|
|
17510
|
+
docId,
|
|
17511
|
+
{
|
|
17512
|
+
force: purgeOptions?.force,
|
|
17513
|
+
scope: purgeOptions?.scope ?? options.largeDocumentStoreScope?.() ?? void 0
|
|
17514
|
+
},
|
|
17515
|
+
ormContext
|
|
17516
|
+
),
|
|
17517
|
+
format2PurgeAll: (purgeOptions) => format2PurgeAll(
|
|
17518
|
+
dbEngine,
|
|
17519
|
+
{
|
|
17520
|
+
...purgeOptions?.keep ? { keep: purgeOptions.keep } : {},
|
|
17521
|
+
force: purgeOptions?.force,
|
|
17522
|
+
scope: purgeOptions?.scope ?? options.largeDocumentStoreScope?.() ?? void 0
|
|
17523
|
+
},
|
|
17524
|
+
ormContext
|
|
17525
|
+
),
|
|
16091
17526
|
format2Acknowledge: (docId, maxContiguousSeq) => format2Acknowledge(docId, maxContiguousSeq, ormContext),
|
|
16092
17527
|
format2NoteEpoch: (docId, epoch) => format2NoteEpoch(docId, epoch, ormContext),
|
|
16093
17528
|
format2NoteSync: (docId, at, windowDays) => format2NoteSync(docId, at, windowDays, ormContext),
|
|
16094
17529
|
format2NoteWindow: (docId, windowDays) => format2NoteWindow(docId, windowDays, ormContext),
|
|
17530
|
+
format2OnWriteRefused: (listener) => ormContext.onFormat2WriteRefused(listener),
|
|
16095
17531
|
format2NoteClockOffset: (docId, offsetMs) => format2NoteClockOffset(docId, offsetMs, ormContext),
|
|
16096
17532
|
format2ForgetPendingOps: (docId, seqs) => format2ForgetPendingOps(docId, seqs, ormContext),
|
|
16097
17533
|
format2NoteDiscontinuity: (docId, epoch) => format2NoteDiscontinuity(docId, epoch, ormContext),
|
|
16098
17534
|
format2ClearDiscontinuities: (docId, through) => format2ClearDiscontinuities(docId, through, ormContext),
|
|
17535
|
+
format2NoteDeferredReplay: (docId, note) => format2NoteDeferredReplay(docId, note, ormContext),
|
|
17536
|
+
format2ClearDeferredReplay: (docId) => format2ClearDeferredReplay(docId, ormContext),
|
|
16099
17537
|
format2NarrowPendingOps: (docId, ops) => format2NarrowPendingOps(docId, ops, ormContext),
|
|
16100
17538
|
format2PendingOps: (docId) => format2PendingOps(docId, ormContext),
|
|
16101
|
-
format2SetEpoch: (docId, epoch) => format2SetEpoch(docId, epoch, ormContext),
|
|
17539
|
+
format2SetEpoch: (docId, epoch, deferredReplay) => format2SetEpoch(docId, epoch, ormContext, deferredReplay),
|
|
16102
17540
|
format2HoldWrites: (docId, run2) => format2HoldWrites(docId, run2, ormContext),
|
|
16103
17541
|
format2RebindEpochDocument: docManager.rebindEpochDocument.bind(docManager),
|
|
16104
17542
|
format2ApplySealedOverlay: docManager.applySealedOverlay.bind(docManager),
|
|
@@ -16775,8 +18213,11 @@ init_format2Observer();
|
|
|
16775
18213
|
|
|
16776
18214
|
// src/models/format2EpochHandoff.ts
|
|
16777
18215
|
init_largeDocuments();
|
|
18216
|
+
function format2EpochDocPrefix(docId) {
|
|
18217
|
+
return `${docId}@`;
|
|
18218
|
+
}
|
|
16778
18219
|
function format2EpochDocName(docId, epoch) {
|
|
16779
|
-
return `${docId}
|
|
18220
|
+
return `${format2EpochDocPrefix(docId)}${epoch}`;
|
|
16780
18221
|
}
|
|
16781
18222
|
function carryUnackedOverlay(ops, readOverlay, options = {}) {
|
|
16782
18223
|
const suppressed = new Set(
|
|
@@ -16924,6 +18365,19 @@ async function followEpoch(host, nextEpoch, options = {}) {
|
|
|
16924
18365
|
const owedOps = host.pendingOps();
|
|
16925
18366
|
const readSealed = (model, recordId) => readOverlayRecord(sealed.getMap(model), recordId);
|
|
16926
18367
|
const owedOverlay = defer ? snapshotOwedOverlay(owedOps, readSealed) : null;
|
|
18368
|
+
const owedNote = defer && owedOps.length > 0 && host.noteDeferredReplay ? {
|
|
18369
|
+
throughSeq: Math.max(...owedOps.map((op) => op.seq)),
|
|
18370
|
+
fromEpoch: current,
|
|
18371
|
+
toEpoch: nextEpoch,
|
|
18372
|
+
kind: "ordinary"
|
|
18373
|
+
} : null;
|
|
18374
|
+
if (owedNote && host.noteDeferredReplay) {
|
|
18375
|
+
try {
|
|
18376
|
+
await host.noteDeferredReplay(owedNote);
|
|
18377
|
+
} catch {
|
|
18378
|
+
return { moved: false, reason: "note-refused" };
|
|
18379
|
+
}
|
|
18380
|
+
}
|
|
16927
18381
|
let carried = [];
|
|
16928
18382
|
if (!defer) {
|
|
16929
18383
|
const owed = resolveReplay ? resolveReplay(owedOps) : { ops: owedOps, suppressDeletes: [] };
|
|
@@ -16944,7 +18398,10 @@ async function followEpoch(host, nextEpoch, options = {}) {
|
|
|
16944
18398
|
await host.install(
|
|
16945
18399
|
fresh,
|
|
16946
18400
|
nextEpoch,
|
|
16947
|
-
defer ? {
|
|
18401
|
+
defer ? {
|
|
18402
|
+
restorePending: false,
|
|
18403
|
+
...owedNote ? { deferredReplay: owedNote } : {}
|
|
18404
|
+
} : void 0
|
|
16948
18405
|
);
|
|
16949
18406
|
await host.resync();
|
|
16950
18407
|
if (!defer) {
|