@syncular/client 0.30.11 → 0.30.12
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/README.md +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.js +75 -59
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +115 -76
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.12",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.12"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.12",
|
|
96
96
|
"playwright": "1.63.0",
|
|
97
97
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
|
|
2
3
|
/**
|
|
3
4
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
type CompiledClientSchema,
|
|
20
21
|
type CompiledClientTable,
|
|
21
22
|
quoteIdent,
|
|
23
|
+
matchesLocalScopes,
|
|
24
|
+
type JsonRowValue,
|
|
22
25
|
SYNC_VERSION_COLUMN,
|
|
23
26
|
toSqlValue,
|
|
24
27
|
rowValueToJson,
|
|
@@ -93,7 +96,8 @@ export async function applyCommitFrame(
|
|
|
93
96
|
schema: CompiledClientSchema,
|
|
94
97
|
frame: CommitFrame,
|
|
95
98
|
encryption?: EncryptionConfig,
|
|
96
|
-
transaction:
|
|
99
|
+
transaction: (fn: () => readonly string[] | undefined) => unknown = (fn) =>
|
|
100
|
+
db.transaction(fn),
|
|
97
101
|
): Promise<void> {
|
|
98
102
|
type Resolved =
|
|
99
103
|
| { op: 'delete'; table: CompiledClientTable; rowId: string }
|
|
@@ -138,14 +142,31 @@ export async function applyCommitFrame(
|
|
|
138
142
|
});
|
|
139
143
|
}
|
|
140
144
|
transaction(() => {
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
const rowKeys = frame.tables.some((name) =>
|
|
146
|
+
schema.tables.get(name)?.indexes.some((index) => index.unique),
|
|
147
|
+
)
|
|
148
|
+
? undefined
|
|
149
|
+
: frame.changes.map((change) =>
|
|
150
|
+
JSON.stringify([frame.tables[change.tableIndex], change.rowId]),
|
|
151
|
+
);
|
|
152
|
+
restoreFailedBases(db, schema, false, rowKeys);
|
|
153
|
+
for (const [index, change] of resolved.entries()) {
|
|
154
|
+
const rowId = frame.changes[index]!.rowId;
|
|
155
|
+
db.exec(
|
|
156
|
+
"UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)",
|
|
157
|
+
[frame.commitSeq, change.table.name, rowId],
|
|
158
|
+
);
|
|
159
|
+
db.exec(
|
|
160
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
161
|
+
[change.table.name, rowId, frame.commitSeq],
|
|
162
|
+
);
|
|
143
163
|
if (change.op === 'delete') {
|
|
144
164
|
deleteLocalRow(db, change.table, change.rowId);
|
|
145
165
|
} else {
|
|
146
166
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
147
167
|
}
|
|
148
168
|
}
|
|
169
|
+
return rowKeys;
|
|
149
170
|
});
|
|
150
171
|
}
|
|
151
172
|
|
|
@@ -195,39 +216,43 @@ export function validateSegmentColumns(
|
|
|
195
216
|
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
196
217
|
* precision or nothing, never clear-the-table.
|
|
197
218
|
*/
|
|
219
|
+
export function localScopePredicate(
|
|
220
|
+
table: CompiledClientTable,
|
|
221
|
+
effective: ScopeMap,
|
|
222
|
+
): { sql: string; params: string[] } | undefined {
|
|
223
|
+
const entries = Object.entries(effective);
|
|
224
|
+
if (!entries.length || entries.some(([, values]) => !values.length)) return;
|
|
225
|
+
const params: string[] = [];
|
|
226
|
+
const sql = entries
|
|
227
|
+
.map(([variable, values]) => {
|
|
228
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
229
|
+
if (column === undefined)
|
|
230
|
+
throw new ClientSyncError(
|
|
231
|
+
'sync.scope_revoked',
|
|
232
|
+
'table has no local scope-column mapping',
|
|
233
|
+
);
|
|
234
|
+
params.push(...values);
|
|
235
|
+
return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
|
|
236
|
+
})
|
|
237
|
+
.join(' AND ');
|
|
238
|
+
return { sql, params };
|
|
239
|
+
}
|
|
240
|
+
|
|
198
241
|
export function deleteScopedRows(
|
|
199
242
|
db: ClientDatabase,
|
|
200
243
|
table: CompiledClientTable,
|
|
201
244
|
effective: ScopeMap,
|
|
202
245
|
): void {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
const
|
|
206
|
-
const params: string[] = [];
|
|
207
|
-
for (const [variable, values] of entries) {
|
|
208
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
209
|
-
if (column === undefined) {
|
|
210
|
-
throw new ClientSyncError(
|
|
211
|
-
'sync.scope_revoked',
|
|
212
|
-
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
if (values.length === 0) return;
|
|
216
|
-
clauses.push(
|
|
217
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
218
|
-
);
|
|
219
|
-
params.push(...values);
|
|
220
|
-
}
|
|
246
|
+
const scope = localScopePredicate(table, effective);
|
|
247
|
+
if (!scope) return;
|
|
248
|
+
const { sql, params } = scope;
|
|
221
249
|
for (const row of db.query(
|
|
222
|
-
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${
|
|
250
|
+
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
|
|
223
251
|
params,
|
|
224
252
|
)) {
|
|
225
253
|
retainedBaseWrite(db, table, String(row.id));
|
|
226
254
|
}
|
|
227
|
-
db.exec(
|
|
228
|
-
`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
229
|
-
params,
|
|
230
|
-
);
|
|
255
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
/**
|
|
@@ -246,24 +271,9 @@ export function evictScopedRows(
|
|
|
246
271
|
effective: ScopeMap,
|
|
247
272
|
pinnedRowIds: ReadonlySet<string>,
|
|
248
273
|
): { remaining: boolean; deferred: boolean } {
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
const
|
|
252
|
-
const params: string[] = [];
|
|
253
|
-
for (const [variable, values] of entries) {
|
|
254
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
255
|
-
if (column === undefined) {
|
|
256
|
-
throw new ClientSyncError(
|
|
257
|
-
'sync.scope_revoked',
|
|
258
|
-
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
if (values.length === 0) return { remaining: false, deferred: false };
|
|
262
|
-
clauses.push(
|
|
263
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
264
|
-
);
|
|
265
|
-
params.push(...values);
|
|
266
|
-
}
|
|
274
|
+
const scope = localScopePredicate(table, effective);
|
|
275
|
+
if (!scope) return { remaining: false, deferred: false };
|
|
276
|
+
const { sql, params } = scope;
|
|
267
277
|
const pk = quoteIdent(table.primaryKey);
|
|
268
278
|
let pinnedClause = '';
|
|
269
279
|
if (pinnedRowIds.size > 0) {
|
|
@@ -272,19 +282,40 @@ export function evictScopedRows(
|
|
|
272
282
|
params.push(...ids);
|
|
273
283
|
}
|
|
274
284
|
const target = quoteIdent(table.name);
|
|
285
|
+
for (const row of db.query(
|
|
286
|
+
`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`,
|
|
287
|
+
[table.name],
|
|
288
|
+
)) {
|
|
289
|
+
if (
|
|
290
|
+
!pinnedRowIds.has(row.id as string) &&
|
|
291
|
+
[row.intent, row.base].some(
|
|
292
|
+
(json) =>
|
|
293
|
+
json !== null &&
|
|
294
|
+
matchesLocalScopes(
|
|
295
|
+
table,
|
|
296
|
+
effective,
|
|
297
|
+
JSON.parse(json as string) as Record<string, JsonRowValue>,
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
db.exec(
|
|
302
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`,
|
|
303
|
+
[table.name, row.id as string],
|
|
304
|
+
);
|
|
305
|
+
}
|
|
275
306
|
db.exec(
|
|
276
|
-
`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${
|
|
307
|
+
`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`,
|
|
277
308
|
params,
|
|
278
309
|
);
|
|
279
310
|
const remaining =
|
|
280
311
|
db.query(
|
|
281
|
-
`SELECT 1 FROM ${target} WHERE ${
|
|
312
|
+
`SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`,
|
|
282
313
|
params,
|
|
283
314
|
).length > 0;
|
|
284
315
|
const deferred =
|
|
285
316
|
remaining ||
|
|
286
317
|
db.query(
|
|
287
|
-
`SELECT 1 FROM ${target} WHERE ${
|
|
318
|
+
`SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`,
|
|
288
319
|
params.slice(0, params.length - pinnedRowIds.size),
|
|
289
320
|
).length > 0;
|
|
290
321
|
return { remaining, deferred };
|
|
@@ -329,7 +360,7 @@ export async function applySqliteSegment(
|
|
|
329
360
|
const withImage = db.withSqliteImage?.bind(db);
|
|
330
361
|
if (withImage === undefined) {
|
|
331
362
|
throw invalidRequest(
|
|
332
|
-
'
|
|
363
|
+
'database cannot import images; disable accept bit 2 (§4.2)',
|
|
333
364
|
);
|
|
334
365
|
}
|
|
335
366
|
if (descriptor.table !== table.name) {
|
|
@@ -506,12 +537,12 @@ export async function applyRowsSegment(
|
|
|
506
537
|
first = false;
|
|
507
538
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
508
539
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
509
|
-
restoreFailedBases(db, schema);
|
|
540
|
+
const protectedRows = restoreFailedBases(db, schema);
|
|
510
541
|
if (clearThisBlock) {
|
|
511
542
|
deleteScopedRows(db, table, options.effective);
|
|
512
543
|
}
|
|
513
544
|
for (const row of rows) {
|
|
514
|
-
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
545
|
+
upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
|
|
515
546
|
applied += 1;
|
|
516
547
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
517
548
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
import {
|
|
2
3
|
dropFailedRows,
|
|
3
4
|
failedOverlayCommits,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
applySqliteSegment,
|
|
52
53
|
deleteLocalRow,
|
|
53
54
|
deleteScopedRows,
|
|
55
|
+
localScopePredicate,
|
|
54
56
|
evictScopedRows,
|
|
55
57
|
yieldToHost,
|
|
56
58
|
upsertLocalRow,
|
|
@@ -196,6 +198,7 @@ import {
|
|
|
196
198
|
type CompiledClientSchema,
|
|
197
199
|
type CompiledClientTable,
|
|
198
200
|
compileClientSchema,
|
|
201
|
+
matchesLocalScopes,
|
|
199
202
|
dropAndRecreateSyncedTables,
|
|
200
203
|
ensureLocalBookkeepingSchema,
|
|
201
204
|
ensureLocalSyncedSchema,
|
|
@@ -837,7 +840,7 @@ export class SyncClient {
|
|
|
837
840
|
constructor(config: SyncClientConfig) {
|
|
838
841
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
839
842
|
throw invalidRequest(
|
|
840
|
-
'securityPreflight and encryption are mutually exclusive;
|
|
843
|
+
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
841
844
|
);
|
|
842
845
|
}
|
|
843
846
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
@@ -923,7 +926,7 @@ export class SyncClient {
|
|
|
923
926
|
this.#lease = undefined;
|
|
924
927
|
throw new ClientSyncError(
|
|
925
928
|
'client.identity_mismatch',
|
|
926
|
-
`
|
|
929
|
+
`database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
|
|
927
930
|
);
|
|
928
931
|
}
|
|
929
932
|
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
@@ -961,6 +964,9 @@ export class SyncClient {
|
|
|
961
964
|
const startupWork =
|
|
962
965
|
this.#schemaFloor === undefined &&
|
|
963
966
|
(countOutbox(this.#db) > 0 ||
|
|
967
|
+
this.#db.query(
|
|
968
|
+
`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`,
|
|
969
|
+
).length > 0 ||
|
|
964
970
|
loadPendingEvictions(this.#db).length > 0 ||
|
|
965
971
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
966
972
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
@@ -1060,6 +1066,7 @@ export class SyncClient {
|
|
|
1060
1066
|
this.#schema,
|
|
1061
1067
|
true,
|
|
1062
1068
|
);
|
|
1069
|
+
this.#discardAcknowledgedRows();
|
|
1063
1070
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1064
1071
|
resetSubscriptionsForBump(this.#db);
|
|
1065
1072
|
setMeta(
|
|
@@ -1160,6 +1167,7 @@ export class SyncClient {
|
|
|
1160
1167
|
this.#sweepPreviousVersionContainer();
|
|
1161
1168
|
this.#applyBatch((batch) => {
|
|
1162
1169
|
this.#db.transaction(() => {
|
|
1170
|
+
this.#discardAcknowledgedRows();
|
|
1163
1171
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1164
1172
|
resetSubscriptionsForBump(this.#db);
|
|
1165
1173
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -1981,7 +1989,9 @@ export class SyncClient {
|
|
|
1981
1989
|
result = fn(batch);
|
|
1982
1990
|
if (
|
|
1983
1991
|
this.#db
|
|
1984
|
-
.query(
|
|
1992
|
+
.query(
|
|
1993
|
+
`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
|
|
1994
|
+
)
|
|
1985
1995
|
.some((row) => batch.hasTable(String(row.tbl)))
|
|
1986
1996
|
)
|
|
1987
1997
|
batch.outcomes();
|
|
@@ -2327,7 +2337,7 @@ export class SyncClient {
|
|
|
2327
2337
|
return this.#applyBatch(
|
|
2328
2338
|
(batch) => {
|
|
2329
2339
|
restoreFailedBases(this.#db, this.#schema);
|
|
2330
|
-
this.#db.exec(
|
|
2340
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
2331
2341
|
input.clientCommitId,
|
|
2332
2342
|
]);
|
|
2333
2343
|
const resolved = persistCommitOutcomeResolution(
|
|
@@ -2475,7 +2485,7 @@ export class SyncClient {
|
|
|
2475
2485
|
if (!sameIntent) {
|
|
2476
2486
|
throw new ClientSyncError(
|
|
2477
2487
|
'client.subscription_intent_mismatch',
|
|
2478
|
-
'
|
|
2488
|
+
'subscription id already names different table, scopes, or params',
|
|
2479
2489
|
);
|
|
2480
2490
|
}
|
|
2481
2491
|
return;
|
|
@@ -2533,7 +2543,7 @@ export class SyncClient {
|
|
|
2533
2543
|
const table = this.#table(base.table);
|
|
2534
2544
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
2535
2545
|
throw invalidRequest(
|
|
2536
|
-
|
|
2546
|
+
`${base.table}: unknown scope variable ${base.variable} (§4.8)`,
|
|
2537
2547
|
);
|
|
2538
2548
|
}
|
|
2539
2549
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
@@ -2965,26 +2975,13 @@ export class SyncClient {
|
|
|
2965
2975
|
return this.#applyBatch((batch) => {
|
|
2966
2976
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2967
2977
|
restoreFailedBases(this.#db, this.#schema);
|
|
2968
|
-
|
|
2978
|
+
this.#dropRetainedRows(batch, (table, values) =>
|
|
2969
2979
|
(targetsByTable.get(table) ?? []).some((target) =>
|
|
2970
2980
|
target.selectors.every((selector) =>
|
|
2971
2981
|
selector.values.includes(String(values[selector.column])),
|
|
2972
2982
|
),
|
|
2973
2983
|
),
|
|
2974
2984
|
);
|
|
2975
|
-
for (const commit of droppedRetained) {
|
|
2976
|
-
batch.conflicts();
|
|
2977
|
-
batch.rejections();
|
|
2978
|
-
this.#conflicts = this.#conflicts.filter(
|
|
2979
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
2980
|
-
);
|
|
2981
|
-
this.#rejections = this.#rejections.filter(
|
|
2982
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
2983
|
-
);
|
|
2984
|
-
for (const operation of commit.operations)
|
|
2985
|
-
batch.table(operation.table);
|
|
2986
|
-
batch.outcomes();
|
|
2987
|
-
}
|
|
2988
2985
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2989
2986
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2990
2987
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -3144,6 +3141,7 @@ export class SyncClient {
|
|
|
3144
3141
|
this.#upgrading = true;
|
|
3145
3142
|
this.#needsPull = true;
|
|
3146
3143
|
batch.status();
|
|
3144
|
+
this.#discardAcknowledgedRows();
|
|
3147
3145
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
3148
3146
|
resetSubscriptionsForBump(this.#db);
|
|
3149
3147
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -4151,6 +4149,7 @@ export class SyncClient {
|
|
|
4151
4149
|
index += 1;
|
|
4152
4150
|
}
|
|
4153
4151
|
}
|
|
4152
|
+
const previousNeedsPull = this.#needsPull;
|
|
4154
4153
|
const conflictCount = this.#conflicts.length;
|
|
4155
4154
|
const rejectionCount = this.#rejections.length;
|
|
4156
4155
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -4183,11 +4182,12 @@ export class SyncClient {
|
|
|
4183
4182
|
},
|
|
4184
4183
|
() => this.#statusSnapshot(outboxCount),
|
|
4185
4184
|
(cause) => {
|
|
4185
|
+
this.#needsPull = previousNeedsPull;
|
|
4186
4186
|
this.#conflicts.length = conflictCount;
|
|
4187
4187
|
this.#rejections.length = rejectionCount;
|
|
4188
4188
|
const error = new ClientSyncError(
|
|
4189
4189
|
'client.outcome_persistence_failed',
|
|
4190
|
-
'
|
|
4190
|
+
'outcome persistence failed',
|
|
4191
4191
|
);
|
|
4192
4192
|
error.cause = cause;
|
|
4193
4193
|
throw error;
|
|
@@ -4485,6 +4485,18 @@ export class SyncClient {
|
|
|
4485
4485
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
4486
4486
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
4487
4487
|
// "already applied, you may have missed the ack".
|
|
4488
|
+
if (frame.commitSeq === undefined)
|
|
4489
|
+
throw new ClientSyncError(
|
|
4490
|
+
'client.invalid_host_response',
|
|
4491
|
+
'ACK lacks a commit sequence',
|
|
4492
|
+
);
|
|
4493
|
+
retainFailedRows(
|
|
4494
|
+
this.#db,
|
|
4495
|
+
commit,
|
|
4496
|
+
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4497
|
+
frame.commitSeq,
|
|
4498
|
+
);
|
|
4499
|
+
this.#setSyncNeeded(true);
|
|
4488
4500
|
recordCommitOutcome(this.#db, {
|
|
4489
4501
|
clientCommitId: frame.clientCommitId,
|
|
4490
4502
|
status: frame.status,
|
|
@@ -4638,12 +4650,7 @@ export class SyncClient {
|
|
|
4638
4650
|
this.#applyBatch((batch) => {
|
|
4639
4651
|
this.#recordCommitChanges(batch, frame);
|
|
4640
4652
|
const result = fn();
|
|
4641
|
-
|
|
4642
|
-
this.#retainFailedCommits &&
|
|
4643
|
-
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
4644
|
-
.length > 0
|
|
4645
|
-
)
|
|
4646
|
-
this.#replayOutbox();
|
|
4653
|
+
this.#replayOutbox(result);
|
|
4647
4654
|
return result;
|
|
4648
4655
|
}),
|
|
4649
4656
|
);
|
|
@@ -4695,22 +4702,12 @@ export class SyncClient {
|
|
|
4695
4702
|
|
|
4696
4703
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
4697
4704
|
#scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
|
|
4698
|
-
const
|
|
4699
|
-
if (entries.length === 0) return false;
|
|
4700
|
-
const clauses: string[] = [];
|
|
4701
|
-
const params: string[] = [];
|
|
4702
|
-
for (const [variable, values] of entries) {
|
|
4703
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
4704
|
-
if (column === undefined || values.length === 0) return false;
|
|
4705
|
-
clauses.push(
|
|
4706
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
4707
|
-
);
|
|
4708
|
-
params.push(...values);
|
|
4709
|
-
}
|
|
4705
|
+
const scope = localScopePredicate(table, effective);
|
|
4710
4706
|
return (
|
|
4707
|
+
scope !== undefined &&
|
|
4711
4708
|
this.#db.query(
|
|
4712
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${
|
|
4713
|
-
params,
|
|
4709
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
|
|
4710
|
+
scope.params,
|
|
4714
4711
|
).length > 0
|
|
4715
4712
|
);
|
|
4716
4713
|
}
|
|
@@ -4817,7 +4814,7 @@ export class SyncClient {
|
|
|
4817
4814
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
4818
4815
|
throw new ClientSyncError(
|
|
4819
4816
|
'sync.segment_expired',
|
|
4820
|
-
`
|
|
4817
|
+
`segment download grant expired; pull again (§5.4)`,
|
|
4821
4818
|
true,
|
|
4822
4819
|
);
|
|
4823
4820
|
}
|
|
@@ -4875,6 +4872,34 @@ export class SyncClient {
|
|
|
4875
4872
|
status: 'active',
|
|
4876
4873
|
}),
|
|
4877
4874
|
);
|
|
4875
|
+
if (completed) {
|
|
4876
|
+
const table = this.#table(sub.table);
|
|
4877
|
+
const effective = start.effectiveScopes;
|
|
4878
|
+
const covered = this.#db
|
|
4879
|
+
.query(
|
|
4880
|
+
`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`,
|
|
4881
|
+
[sub.table, nextCursor],
|
|
4882
|
+
)
|
|
4883
|
+
.filter((row) =>
|
|
4884
|
+
[row.intent, row.base].some((json) => {
|
|
4885
|
+
if (json === null) return false;
|
|
4886
|
+
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
4887
|
+
String(json),
|
|
4888
|
+
);
|
|
4889
|
+
return matchesLocalScopes(table, effective, values);
|
|
4890
|
+
}),
|
|
4891
|
+
);
|
|
4892
|
+
if (covered.length) {
|
|
4893
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
4894
|
+
for (const row of covered)
|
|
4895
|
+
this.#db.exec(
|
|
4896
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
4897
|
+
[sub.table, String(row.id), nextCursor],
|
|
4898
|
+
);
|
|
4899
|
+
batch.table(sub.table);
|
|
4900
|
+
this.#replayOutbox();
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4878
4903
|
if (completed && registered !== undefined) {
|
|
4879
4904
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
4880
4905
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -4923,29 +4948,12 @@ export class SyncClient {
|
|
|
4923
4948
|
) {
|
|
4924
4949
|
try {
|
|
4925
4950
|
restoreFailedBases(this.#db, this.#schema);
|
|
4926
|
-
|
|
4927
|
-
|
|
4951
|
+
this.#dropRetainedRows(
|
|
4952
|
+
batch,
|
|
4928
4953
|
(name, values) =>
|
|
4929
4954
|
name === table.name &&
|
|
4930
|
-
|
|
4931
|
-
allowed.includes(
|
|
4932
|
-
String(values[table.scopeColumnByVariable.get(variable)!]),
|
|
4933
|
-
),
|
|
4934
|
-
),
|
|
4955
|
+
matchesLocalScopes(table, lastEffective, values),
|
|
4935
4956
|
);
|
|
4936
|
-
for (const commit of droppedRetained) {
|
|
4937
|
-
batch.conflicts();
|
|
4938
|
-
batch.rejections();
|
|
4939
|
-
this.#conflicts = this.#conflicts.filter(
|
|
4940
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
4941
|
-
);
|
|
4942
|
-
this.#rejections = this.#rejections.filter(
|
|
4943
|
-
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
4944
|
-
);
|
|
4945
|
-
for (const operation of commit.operations)
|
|
4946
|
-
batch.table(operation.table);
|
|
4947
|
-
batch.outcomes();
|
|
4948
|
-
}
|
|
4949
4957
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4950
4958
|
batch.scopeMap(table, lastEffective);
|
|
4951
4959
|
const pendingById = new Map(
|
|
@@ -5229,18 +5237,52 @@ export class SyncClient {
|
|
|
5229
5237
|
}
|
|
5230
5238
|
|
|
5231
5239
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5232
|
-
#
|
|
5240
|
+
#discardAcknowledgedRows(): void {
|
|
5241
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
#dropRetainedRows(
|
|
5245
|
+
batch: ChangeAccumulator,
|
|
5246
|
+
matches: (
|
|
5247
|
+
table: string,
|
|
5248
|
+
values: Readonly<Record<string, JsonRowValue>>,
|
|
5249
|
+
) => boolean,
|
|
5250
|
+
): void {
|
|
5251
|
+
for (const commit of dropFailedRows(this.#db, matches)) {
|
|
5252
|
+
batch.conflicts();
|
|
5253
|
+
batch.rejections();
|
|
5254
|
+
batch.outcomes();
|
|
5255
|
+
this.#conflicts = this.#conflicts.filter(
|
|
5256
|
+
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5257
|
+
);
|
|
5258
|
+
this.#rejections = this.#rejections.filter(
|
|
5259
|
+
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5260
|
+
);
|
|
5261
|
+
for (const op of commit.operations) batch.table(op.table);
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
#replayOutbox(rowKeys?: readonly string[]): void {
|
|
5266
|
+
const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
|
|
5233
5267
|
const pending = [
|
|
5234
|
-
...failedOverlayCommits(this.#db),
|
|
5235
|
-
...listOutbox(this.#db),
|
|
5268
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
5269
|
+
...listOutbox(this.#db, rowKeys),
|
|
5236
5270
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5237
|
-
if (pending.length === 0) return;
|
|
5271
|
+
if (pending.length === 0 && acknowledged.length === 0) return;
|
|
5238
5272
|
this.#applyBatch((batch) => {
|
|
5239
5273
|
this.#db.transaction(() => {
|
|
5240
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
5241
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
5242
|
-
for (const commit of pending)
|
|
5243
|
-
this.#applyOperationsLocally(
|
|
5274
|
+
restoreFailedBases(this.#db, this.#schema, false, rowKeys);
|
|
5275
|
+
restoreFailedBases(this.#db, this.#schema, true, rowKeys);
|
|
5276
|
+
for (const commit of [...acknowledged, ...pending])
|
|
5277
|
+
this.#applyOperationsLocally(
|
|
5278
|
+
rowKeys === undefined
|
|
5279
|
+
? commit.operations
|
|
5280
|
+
: commit.operations.filter((op) =>
|
|
5281
|
+
rowKeys.includes(JSON.stringify([op.table, op.rowId])),
|
|
5282
|
+
),
|
|
5283
|
+
batch,
|
|
5284
|
+
true,
|
|
5285
|
+
);
|
|
5244
5286
|
});
|
|
5245
5287
|
});
|
|
5246
5288
|
}
|
|
@@ -5255,17 +5297,14 @@ export class SyncClient {
|
|
|
5255
5297
|
#table(name: string): CompiledClientTable {
|
|
5256
5298
|
const table = this.#schema.tables.get(name);
|
|
5257
5299
|
if (table === undefined) {
|
|
5258
|
-
throw new ClientSyncError(
|
|
5259
|
-
'sync.unknown_table',
|
|
5260
|
-
`unknown local table ${JSON.stringify(name)}`,
|
|
5261
|
-
);
|
|
5300
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
5262
5301
|
}
|
|
5263
5302
|
return table;
|
|
5264
5303
|
}
|
|
5265
5304
|
|
|
5266
5305
|
#requireStarted(): void {
|
|
5267
5306
|
if (!this.#started) {
|
|
5268
|
-
throw invalidRequest('SyncClient.start()
|
|
5307
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
5269
5308
|
}
|
|
5270
5309
|
}
|
|
5271
5310
|
|
|
@@ -5274,7 +5313,7 @@ export class SyncClient {
|
|
|
5274
5313
|
if (this.#securityLifecycle === 'preflight') {
|
|
5275
5314
|
throw new ClientSyncError(
|
|
5276
5315
|
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
5277
|
-
'
|
|
5316
|
+
'security preflight must complete before accessing protected data; call activateSecurity',
|
|
5278
5317
|
);
|
|
5279
5318
|
}
|
|
5280
5319
|
}
|