@syncular/client 0.30.13 → 0.30.14
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/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.js +51 -38
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- package/package.json +3 -3
- package/src/apply.ts +106 -37
- package/src/blob.ts +4 -3
- package/src/client.ts +77 -48
- package/src/failed-overlay.ts +96 -78
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
package/dist/apply.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
2
|
/**
|
|
2
3
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
3
4
|
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
|
|
|
8
9
|
import type { EncryptionConfig } from './encryption.js';
|
|
9
10
|
import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
|
|
10
11
|
/** Lets the client wrap the physical write in its revision transaction. */
|
|
11
|
-
export type ApplyTransaction =
|
|
12
|
+
export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
|
|
12
13
|
export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
|
|
13
14
|
export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
|
|
14
15
|
/**
|
|
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
|
|
|
23
24
|
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
24
25
|
* never half-applies.
|
|
25
26
|
*/
|
|
26
|
-
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () =>
|
|
27
|
+
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
|
|
27
28
|
/**
|
|
28
29
|
* §5.2: the segment's column table must match the generated schema for
|
|
29
30
|
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
package/dist/apply.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
|
|
1
3
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
4
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
3
5
|
/**
|
|
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
transaction(() => {
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
restoreFailedBases(db, schema,
|
|
81
|
+
const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
|
|
82
|
+
? [change.table.name]
|
|
83
|
+
: [change.table.name, frame.changes[index].rowId]));
|
|
84
|
+
restoreFailedBases(db, schema, scope);
|
|
83
85
|
for (const [index, change] of resolved.entries()) {
|
|
84
86
|
const rowId = frame.changes[index].rowId;
|
|
85
|
-
db.exec(
|
|
87
|
+
db.exec(`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`, [frame.commitSeq, change.table.name, rowId]);
|
|
86
88
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
|
|
87
89
|
if (change.op === 'delete') {
|
|
88
90
|
deleteLocalRow(db, change.table, change.rowId);
|
|
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
91
93
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
|
-
return
|
|
96
|
+
return scope;
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
/**
|
|
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
|
|
|
147
149
|
.join(' AND ');
|
|
148
150
|
return { sql, params };
|
|
149
151
|
}
|
|
152
|
+
function scopedRows(db, table, scope) {
|
|
153
|
+
return scope
|
|
154
|
+
? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
|
|
155
|
+
: [];
|
|
156
|
+
}
|
|
157
|
+
function bootstrapScope(db, table, effective) {
|
|
158
|
+
const rows = scopedRows(db, table, localScopePredicate(table, effective));
|
|
159
|
+
rows.push(...db
|
|
160
|
+
.query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
|
|
161
|
+
table.name,
|
|
162
|
+
])
|
|
163
|
+
.filter((row) => [row.base, row.intent].some((json) => json !== null &&
|
|
164
|
+
matchesLocalScopes(table, effective, JSON.parse(json)))));
|
|
165
|
+
return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
|
|
166
|
+
}
|
|
150
167
|
export function deleteScopedRows(db, table, effective) {
|
|
151
168
|
const scope = localScopePredicate(table, effective);
|
|
152
169
|
if (!scope)
|
|
153
170
|
return;
|
|
154
171
|
const { sql, params } = scope;
|
|
155
|
-
for (const row of db
|
|
172
|
+
for (const row of scopedRows(db, table, scope)) {
|
|
156
173
|
retainedBaseWrite(db, table, String(row.id));
|
|
157
174
|
}
|
|
158
175
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
287
304
|
predicates.push(`${primaryKey} <= ?`);
|
|
288
305
|
params.push(boundary);
|
|
289
306
|
}
|
|
307
|
+
const replay = countOutbox(db) ||
|
|
308
|
+
db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
|
|
309
|
+
table.name,
|
|
310
|
+
]).length;
|
|
311
|
+
const rows = replay
|
|
312
|
+
? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
|
|
313
|
+
: [];
|
|
290
314
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
291
|
-
|
|
315
|
+
const scope = !replay
|
|
316
|
+
? []
|
|
317
|
+
: hasUniqueIndex(table)
|
|
318
|
+
? [JSON.stringify([table.name])]
|
|
319
|
+
: [
|
|
320
|
+
...(after === undefined && options.clearFirst
|
|
321
|
+
? bootstrapScope(db, table, options.effective)
|
|
322
|
+
: []),
|
|
323
|
+
...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
|
|
324
|
+
];
|
|
325
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
292
326
|
if (after === undefined && options.clearFirst)
|
|
293
327
|
deleteScopedRows(db, table, options.effective);
|
|
294
328
|
db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
|
|
295
329
|
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
330
|
+
if (protectedRows)
|
|
331
|
+
for (const row of rows) {
|
|
332
|
+
retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
|
|
333
|
+
column.name,
|
|
334
|
+
rowValueToJson(row[column.name]),
|
|
335
|
+
])), Number(row._syncular_version));
|
|
336
|
+
}
|
|
337
|
+
return scope;
|
|
302
338
|
});
|
|
303
339
|
options.onProgress?.(applied);
|
|
304
340
|
await yieldToHost();
|
|
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
347
383
|
if (!clearThisBlock && rows.length === 0)
|
|
348
384
|
continue;
|
|
349
385
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
350
|
-
const
|
|
386
|
+
const scope = hasUniqueIndex(table)
|
|
387
|
+
? [JSON.stringify([table.name])]
|
|
388
|
+
: [
|
|
389
|
+
...(clearThisBlock
|
|
390
|
+
? bootstrapScope(db, table, options.effective)
|
|
391
|
+
: []),
|
|
392
|
+
...rows.map((row) => JSON.stringify([
|
|
393
|
+
table.name,
|
|
394
|
+
String(row.values[table.primaryKeyIndex]),
|
|
395
|
+
])),
|
|
396
|
+
];
|
|
397
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
351
398
|
if (clearThisBlock) {
|
|
352
399
|
deleteScopedRows(db, table, options.effective);
|
|
353
400
|
}
|
|
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
357
404
|
if (applied % 1024 === 0)
|
|
358
405
|
options.onProgress?.(applied);
|
|
359
406
|
}
|
|
407
|
+
return scope;
|
|
360
408
|
});
|
|
361
409
|
options.onProgress?.(applied);
|
|
362
410
|
await yieldToHost();
|
package/dist/blob.d.ts
CHANGED
package/dist/blob.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
|
|
1
2
|
/**
|
|
2
3
|
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
4
|
*
|
|
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
70
71
|
if (total <= maxBytes)
|
|
71
72
|
return [];
|
|
72
73
|
// Oldest eligible body first, with blob ID as a stable tie.
|
|
73
|
-
const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
74
|
+
const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
|
|
74
75
|
const evicted = [];
|
|
75
76
|
db.transaction(() => {
|
|
76
77
|
for (const row of candidates) {
|
|
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
86
87
|
}
|
|
87
88
|
export function listPendingUploads(db) {
|
|
88
89
|
return db
|
|
89
|
-
.query(
|
|
90
|
+
.query(`SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM ${BLOB_COMMIT_REFS} r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id`)
|
|
90
91
|
.map((row) => {
|
|
91
92
|
if (typeof row.blob_id !== 'string' ||
|
|
92
93
|
(row.media_type !== null && typeof row.media_type !== 'string')) {
|
|
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
|
|
|
138
139
|
: selects.join(' UNION ');
|
|
139
140
|
}
|
|
140
141
|
export function deleteUnreferencedCachedBlobs(db, schema) {
|
|
141
|
-
db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
142
|
+
db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
|
|
142
143
|
}
|
|
143
144
|
export { parseBlobRef, serializeBlobRef };
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
import { hasUniqueIndex } from './schema.js';
|
|
3
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
4
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
5
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
6
|
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
3
7
|
import { ProgressEmitter, } from './progress.js';
|
|
@@ -478,6 +482,13 @@ export class SyncClient {
|
|
|
478
482
|
}
|
|
479
483
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
480
484
|
#runLogEpochReset(logEpoch) {
|
|
485
|
+
if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
|
|
486
|
+
this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
|
|
487
|
+
this.#setSyncNeeded(true);
|
|
488
|
+
this.#emitSyncNeeded('startup');
|
|
489
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
481
492
|
const subscriptions = loadSubscriptions(this.#db);
|
|
482
493
|
const pending = listOutbox(this.#db);
|
|
483
494
|
this.#setUpgrading(true);
|
|
@@ -1128,8 +1139,8 @@ export class SyncClient {
|
|
|
1128
1139
|
try {
|
|
1129
1140
|
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
1130
1141
|
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
1131
|
-
const outboxBytes = Number(this.#db.query(
|
|
1132
|
-
const outcome = this.#db.query(
|
|
1142
|
+
const outboxBytes = Number(this.#db.query(`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`)[0]?.bytes ?? 0);
|
|
1143
|
+
const outcome = this.#db.query(`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`)[0];
|
|
1133
1144
|
const blobBytes = this.#hasBlobs
|
|
1134
1145
|
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
1135
1146
|
: 0;
|
|
@@ -1925,7 +1936,7 @@ export class SyncClient {
|
|
|
1925
1936
|
catch {
|
|
1926
1937
|
continue;
|
|
1927
1938
|
}
|
|
1928
|
-
this.#db.exec(
|
|
1939
|
+
this.#db.exec(`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`, [clientCommitId, blobId, blobId]);
|
|
1929
1940
|
}
|
|
1930
1941
|
}
|
|
1931
1942
|
try {
|
|
@@ -2250,7 +2261,7 @@ export class SyncClient {
|
|
|
2250
2261
|
async #encodeOutboxForPush() {
|
|
2251
2262
|
// Pin before the first encryption await: mutations can append while a
|
|
2252
2263
|
// round is encoding, and belong to the next request.
|
|
2253
|
-
const bounds = this.#db.query(
|
|
2264
|
+
const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
|
|
2254
2265
|
const pendingCount = bounds.count;
|
|
2255
2266
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2256
2267
|
const pushFrames = [];
|
|
@@ -3140,14 +3151,7 @@ export class SyncClient {
|
|
|
3140
3151
|
}
|
|
3141
3152
|
}
|
|
3142
3153
|
finally {
|
|
3143
|
-
|
|
3144
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
3145
|
-
// the still-pending commits on top of the freshly applied server state.
|
|
3146
|
-
this.#replayOutbox();
|
|
3147
|
-
}
|
|
3148
|
-
finally {
|
|
3149
|
-
this.#endDiagnosticsDeferral();
|
|
3150
|
-
}
|
|
3154
|
+
this.#endDiagnosticsDeferral();
|
|
3151
3155
|
}
|
|
3152
3156
|
if (errorFrame !== undefined)
|
|
3153
3157
|
throw errorFrame;
|
|
@@ -3276,13 +3280,16 @@ export class SyncClient {
|
|
|
3276
3280
|
if (this.#retainFailedCommits)
|
|
3277
3281
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3278
3282
|
this.#rollbackFailedCommit(commit, batch);
|
|
3283
|
+
this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
|
|
3284
|
+
? [operation.table]
|
|
3285
|
+
: [operation.table, operation.rowId])));
|
|
3279
3286
|
});
|
|
3280
3287
|
batch.status();
|
|
3281
3288
|
summary.rejected.push(frame.clientCommitId);
|
|
3282
3289
|
return true;
|
|
3283
3290
|
}
|
|
3284
3291
|
#outboxCommitExists(clientCommitId) {
|
|
3285
|
-
return (this.#db.query(
|
|
3292
|
+
return (this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [clientCommitId]).length > 0);
|
|
3286
3293
|
}
|
|
3287
3294
|
#decodeServerRow(tableName, payload) {
|
|
3288
3295
|
if (tableName === undefined)
|
|
@@ -3320,7 +3327,7 @@ export class SyncClient {
|
|
|
3320
3327
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3321
3328
|
this.#recordCommitChanges(batch, frame);
|
|
3322
3329
|
const result = fn();
|
|
3323
|
-
this.#replayOutbox(result);
|
|
3330
|
+
this.#replayOutbox(result, false);
|
|
3324
3331
|
return result;
|
|
3325
3332
|
}));
|
|
3326
3333
|
summary.commitsApplied += 1;
|
|
@@ -3381,7 +3388,7 @@ export class SyncClient {
|
|
|
3381
3388
|
(clearFirst && this.#scopedRowsExist(table, effective)))
|
|
3382
3389
|
batch.table(table.name);
|
|
3383
3390
|
const result = fn();
|
|
3384
|
-
this.#replayOutbox();
|
|
3391
|
+
this.#replayOutbox(result, false);
|
|
3385
3392
|
return result;
|
|
3386
3393
|
}),
|
|
3387
3394
|
};
|
|
@@ -3513,11 +3520,12 @@ export class SyncClient {
|
|
|
3513
3520
|
return matchesLocalScopes(table, effective, values);
|
|
3514
3521
|
}));
|
|
3515
3522
|
if (covered.length) {
|
|
3516
|
-
|
|
3523
|
+
const scope = covered.map((row) => JSON.stringify([sub.table, String(row.id)]));
|
|
3524
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3517
3525
|
for (const row of covered)
|
|
3518
3526
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
|
|
3519
3527
|
batch.table(sub.table);
|
|
3520
|
-
this.#replayOutbox();
|
|
3528
|
+
this.#replayOutbox(scope, false);
|
|
3521
3529
|
}
|
|
3522
3530
|
}
|
|
3523
3531
|
if (completed && registered !== undefined) {
|
|
@@ -3560,9 +3568,11 @@ export class SyncClient {
|
|
|
3560
3568
|
if (lastEffective !== undefined &&
|
|
3561
3569
|
Object.keys(lastEffective).length > 0) {
|
|
3562
3570
|
try {
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3571
|
+
const scope = [
|
|
3572
|
+
JSON.stringify([table.name]),
|
|
3573
|
+
...this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3574
|
+
matchesLocalScopes(table, lastEffective, values)),
|
|
3575
|
+
];
|
|
3566
3576
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3567
3577
|
batch.scopeMap(table, lastEffective);
|
|
3568
3578
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3600,6 +3610,7 @@ export class SyncClient {
|
|
|
3600
3610
|
batch.rejections();
|
|
3601
3611
|
batch.outcomes();
|
|
3602
3612
|
}
|
|
3613
|
+
this.#replayOutbox(scope);
|
|
3603
3614
|
this.#deleteUnreferencedBlobs();
|
|
3604
3615
|
}
|
|
3605
3616
|
catch (error) {
|
|
@@ -3772,7 +3783,7 @@ export class SyncClient {
|
|
|
3772
3783
|
? OPTIMISTIC_VERSION
|
|
3773
3784
|
: local[SYNC_VERSION_COLUMN];
|
|
3774
3785
|
if (replay &&
|
|
3775
|
-
table
|
|
3786
|
+
hasUniqueIndex(table) &&
|
|
3776
3787
|
uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
|
|
3777
3788
|
column.name,
|
|
3778
3789
|
values[index] ?? null,
|
|
@@ -3786,33 +3797,35 @@ export class SyncClient {
|
|
|
3786
3797
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
3787
3798
|
}
|
|
3788
3799
|
#dropRetainedRows(batch, matches) {
|
|
3789
|
-
|
|
3800
|
+
const scope = [];
|
|
3801
|
+
for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
|
|
3790
3802
|
batch.conflicts();
|
|
3791
3803
|
batch.rejections();
|
|
3792
3804
|
batch.outcomes();
|
|
3793
3805
|
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3794
3806
|
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3795
|
-
for (const op of commit.operations)
|
|
3807
|
+
for (const op of commit.operations) {
|
|
3796
3808
|
batch.table(op.table);
|
|
3809
|
+
scope.push(JSON.stringify([op.table, op.rowId]));
|
|
3810
|
+
}
|
|
3797
3811
|
}
|
|
3812
|
+
return scope;
|
|
3798
3813
|
}
|
|
3799
|
-
#replayOutbox(
|
|
3800
|
-
|
|
3801
|
-
const pending = [
|
|
3802
|
-
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3803
|
-
...listOutbox(this.#db, rowKeys),
|
|
3804
|
-
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3805
|
-
if (pending.length === 0 && acknowledged.length === 0)
|
|
3814
|
+
#replayOutbox(scope, restoreBases = true) {
|
|
3815
|
+
if (scope?.length === 0)
|
|
3806
3816
|
return;
|
|
3807
3817
|
this.#applyBatch((batch) => {
|
|
3808
|
-
|
|
3809
|
-
restoreFailedBases(this.#db, this.#schema,
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3818
|
+
if (restoreBases)
|
|
3819
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3820
|
+
const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
|
|
3821
|
+
const pending = [
|
|
3822
|
+
...failedOverlayCommits(this.#db, false, scope, this.#schema),
|
|
3823
|
+
...listOutbox(this.#db, scope),
|
|
3824
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3825
|
+
for (const commit of [...acknowledged, ...pending])
|
|
3826
|
+
this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
|
|
3827
|
+
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
3828
|
+
scope.includes(JSON.stringify([op.table]))), batch, true);
|
|
3816
3829
|
});
|
|
3817
3830
|
}
|
|
3818
3831
|
#deleteUnreferencedBlobs() {
|
package/dist/failed-overlay.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
|
|
|
10
10
|
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
|
|
11
11
|
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
12
12
|
export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
export declare function
|
|
13
|
+
/** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
|
|
14
|
+
export type OverlayScope = readonly string[];
|
|
15
|
+
export declare function overlayScopePredicate(table: string, id: string): string;
|
|
16
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
|
|
17
|
+
export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
|
|
18
|
+
export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|
package/dist/failed-overlay.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
3
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
4
|
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
2
5
|
import { ClientSyncError } from './errors.js';
|
|
3
6
|
import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
|
17
20
|
table.name,
|
|
18
21
|
rowId,
|
|
19
22
|
]);
|
|
20
|
-
if (!table
|
|
23
|
+
if (!hasUniqueIndex(table))
|
|
21
24
|
return;
|
|
22
25
|
for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
|
|
23
26
|
const prior = retained.unique_conflicts === null
|
|
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
|
|
|
87
90
|
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
88
91
|
})));
|
|
89
92
|
}
|
|
90
|
-
function
|
|
91
|
-
return
|
|
93
|
+
export function overlayScopePredicate(table, id) {
|
|
94
|
+
return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
|
|
92
95
|
}
|
|
93
|
-
|
|
96
|
+
function retainedRows(db, scope, acknowledged) {
|
|
97
|
+
return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`, [scope ? JSON.stringify(scope) : null]);
|
|
98
|
+
}
|
|
99
|
+
function retainedTable(schema, name) {
|
|
100
|
+
const table = schema.tables.get(name);
|
|
101
|
+
if (!table)
|
|
102
|
+
throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
|
|
103
|
+
return table;
|
|
104
|
+
}
|
|
105
|
+
export function restoreFailedBases(db, schema, scope) {
|
|
94
106
|
const seen = new Set();
|
|
95
|
-
for (const row of retainedRows(db,
|
|
107
|
+
for (const row of retainedRows(db, scope)) {
|
|
96
108
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
97
|
-
if (
|
|
109
|
+
if (seen.has(key))
|
|
98
110
|
continue;
|
|
99
111
|
seen.add(key);
|
|
100
|
-
const table = schema
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const table = retainedTable(schema, row.tbl);
|
|
113
|
+
if (row.base === null)
|
|
114
|
+
deleteLocalRow(db, table, row.id, false);
|
|
115
|
+
else {
|
|
116
|
+
const values = JSON.parse(String(row.base));
|
|
117
|
+
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return seen.size > 0;
|
|
121
|
+
}
|
|
122
|
+
export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
|
|
123
|
+
const commits = new Map();
|
|
124
|
+
for (const row of retainedRows(db, scope, acknowledged)) {
|
|
125
|
+
const id = row.commit_id;
|
|
126
|
+
let operation = JSON.parse(row.op);
|
|
127
|
+
if (schema && operation.op === 'upsert') {
|
|
128
|
+
const table = retainedTable(schema, operation.table);
|
|
129
|
+
if (acknowledged) {
|
|
130
|
+
if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
|
|
131
|
+
operation = {
|
|
132
|
+
...operation,
|
|
133
|
+
values: JSON.parse(row.intent),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
else if (row.unique_conflicts === null && row.intent !== null) {
|
|
137
|
+
// Hydrate legacy failure evidence from authoritative unique-key peers.
|
|
138
|
+
const values = JSON.parse(row.intent);
|
|
139
|
+
const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
|
|
140
|
+
!db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
|
|
112
141
|
if (authorized.length)
|
|
113
142
|
db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
|
|
114
143
|
JSON.stringify(authorized.map((conflict) => ({
|
|
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
|
|
|
119
148
|
row.idx,
|
|
120
149
|
]);
|
|
121
150
|
}
|
|
122
|
-
if (conflicts.length)
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (absentIntent &&
|
|
126
|
-
(json === null ||
|
|
127
|
-
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
|
|
128
|
-
continue;
|
|
129
|
-
if (json === null)
|
|
130
|
-
deleteLocalRow(db, table, row.id, false);
|
|
131
|
-
else {
|
|
132
|
-
const values = JSON.parse(String(json));
|
|
133
|
-
if (absentIntent) {
|
|
134
|
-
const operation = JSON.parse(row.op);
|
|
135
|
-
if (row.commit_seq === null &&
|
|
136
|
-
!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
140
151
|
}
|
|
141
|
-
}
|
|
142
|
-
return seen.size > 0;
|
|
143
|
-
}
|
|
144
|
-
export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
145
|
-
const commits = new Map();
|
|
146
|
-
for (const row of retainedRows(db, rowKeys)) {
|
|
147
|
-
if ((row.commit_seq !== null) !== acknowledged)
|
|
148
|
-
continue;
|
|
149
|
-
const id = row.commit_id;
|
|
150
|
-
const operation = JSON.parse(row.op);
|
|
151
152
|
const prior = commits.get(id);
|
|
152
153
|
if (prior)
|
|
153
154
|
prior.operations.push(operation);
|
|
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
|
161
162
|
}
|
|
162
163
|
return [...commits.values()];
|
|
163
164
|
}
|
|
164
|
-
export function dropFailedRows(db, matches) {
|
|
165
|
+
export function dropFailedRows(db, schema, matches) {
|
|
165
166
|
const ids = new Set();
|
|
166
167
|
for (const row of retainedRows(db)) {
|
|
167
168
|
for (const json of [row.intent, row.base]) {
|
|
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
|
|
|
176
177
|
...failedOverlayCommits(db),
|
|
177
178
|
...failedOverlayCommits(db, true),
|
|
178
179
|
].filter((commit) => ids.has(commit.clientCommitId));
|
|
180
|
+
restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
|
|
179
181
|
for (const id of ids) {
|
|
180
182
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
181
|
-
db.exec(
|
|
183
|
+
db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
|
|
182
184
|
}
|
|
183
185
|
return dropped;
|
|
184
186
|
}
|
package/dist/outbox.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export declare const OUTBOX_TABLE = "_syncular_outbox";
|
|
2
|
+
export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
|
|
3
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
4
|
/**
|
|
2
5
|
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
6
|
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
|
|
|
40
43
|
}
|
|
41
44
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
42
45
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
43
|
-
export declare function listOutbox(db: ClientDatabase,
|
|
46
|
+
export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
|
|
44
47
|
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
45
48
|
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
46
49
|
/** Routine status reads never load operation bodies. */
|