@syncular/client 0.30.10 → 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 +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/src/client.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
import {
|
|
2
3
|
dropFailedRows,
|
|
3
4
|
failedOverlayCommits,
|
|
4
5
|
retainFailedRows,
|
|
5
6
|
restoreFailedBases,
|
|
7
|
+
uniqueConflicts,
|
|
6
8
|
} from './failed-overlay';
|
|
7
9
|
import {
|
|
8
10
|
ProgressEmitter,
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
applySqliteSegment,
|
|
51
53
|
deleteLocalRow,
|
|
52
54
|
deleteScopedRows,
|
|
55
|
+
localScopePredicate,
|
|
53
56
|
evictScopedRows,
|
|
54
57
|
yieldToHost,
|
|
55
58
|
upsertLocalRow,
|
|
@@ -195,6 +198,7 @@ import {
|
|
|
195
198
|
type CompiledClientSchema,
|
|
196
199
|
type CompiledClientTable,
|
|
197
200
|
compileClientSchema,
|
|
201
|
+
matchesLocalScopes,
|
|
198
202
|
dropAndRecreateSyncedTables,
|
|
199
203
|
ensureLocalBookkeepingSchema,
|
|
200
204
|
ensureLocalSyncedSchema,
|
|
@@ -219,6 +223,7 @@ import {
|
|
|
219
223
|
getSubscription,
|
|
220
224
|
loadSubscriptions,
|
|
221
225
|
pruneUnknownSubscriptions,
|
|
226
|
+
saveSubscriptionScopeSchema,
|
|
222
227
|
resetSubscriptionsForBump,
|
|
223
228
|
type SubscriptionRecord,
|
|
224
229
|
saveSubscription,
|
|
@@ -835,7 +840,7 @@ export class SyncClient {
|
|
|
835
840
|
constructor(config: SyncClientConfig) {
|
|
836
841
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
837
842
|
throw invalidRequest(
|
|
838
|
-
'securityPreflight and encryption are mutually exclusive;
|
|
843
|
+
'securityPreflight and encryption are mutually exclusive; activateSecurity installs keys',
|
|
839
844
|
);
|
|
840
845
|
}
|
|
841
846
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
@@ -921,7 +926,7 @@ export class SyncClient {
|
|
|
921
926
|
this.#lease = undefined;
|
|
922
927
|
throw new ClientSyncError(
|
|
923
928
|
'client.identity_mismatch',
|
|
924
|
-
`
|
|
929
|
+
`database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`,
|
|
925
930
|
);
|
|
926
931
|
}
|
|
927
932
|
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
@@ -947,7 +952,7 @@ export class SyncClient {
|
|
|
947
952
|
const subscriptions = pruneUnknownSubscriptions(
|
|
948
953
|
this.#db,
|
|
949
954
|
loadSubscriptions(this.#db),
|
|
950
|
-
|
|
955
|
+
this.#schema,
|
|
951
956
|
);
|
|
952
957
|
this.#started = true;
|
|
953
958
|
// A persisted active subscription needs one catch-up round on every open:
|
|
@@ -959,6 +964,9 @@ export class SyncClient {
|
|
|
959
964
|
const startupWork =
|
|
960
965
|
this.#schemaFloor === undefined &&
|
|
961
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 ||
|
|
962
970
|
loadPendingEvictions(this.#db).length > 0 ||
|
|
963
971
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
964
972
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
@@ -1000,6 +1008,7 @@ export class SyncClient {
|
|
|
1000
1008
|
String(this.#schema.version),
|
|
1001
1009
|
);
|
|
1002
1010
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1011
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1003
1012
|
});
|
|
1004
1013
|
return;
|
|
1005
1014
|
}
|
|
@@ -1011,6 +1020,7 @@ export class SyncClient {
|
|
|
1011
1020
|
this.#db.transaction(() => {
|
|
1012
1021
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
1013
1022
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1023
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1014
1024
|
});
|
|
1015
1025
|
return;
|
|
1016
1026
|
}
|
|
@@ -1050,6 +1060,13 @@ export class SyncClient {
|
|
|
1050
1060
|
),
|
|
1051
1061
|
);
|
|
1052
1062
|
}
|
|
1063
|
+
pruneUnknownSubscriptions(
|
|
1064
|
+
this.#db,
|
|
1065
|
+
loadSubscriptions(this.#db),
|
|
1066
|
+
this.#schema,
|
|
1067
|
+
true,
|
|
1068
|
+
);
|
|
1069
|
+
this.#discardAcknowledgedRows();
|
|
1053
1070
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1054
1071
|
resetSubscriptionsForBump(this.#db);
|
|
1055
1072
|
setMeta(
|
|
@@ -1058,6 +1075,7 @@ export class SyncClient {
|
|
|
1058
1075
|
String(this.#schema.version),
|
|
1059
1076
|
);
|
|
1060
1077
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1078
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1061
1079
|
});
|
|
1062
1080
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
1063
1081
|
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
@@ -1149,6 +1167,7 @@ export class SyncClient {
|
|
|
1149
1167
|
this.#sweepPreviousVersionContainer();
|
|
1150
1168
|
this.#applyBatch((batch) => {
|
|
1151
1169
|
this.#db.transaction(() => {
|
|
1170
|
+
this.#discardAcknowledgedRows();
|
|
1152
1171
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1153
1172
|
resetSubscriptionsForBump(this.#db);
|
|
1154
1173
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -1403,13 +1422,16 @@ export class SyncClient {
|
|
|
1403
1422
|
previous.tables.join('\0') !== tables.join('\0')
|
|
1404
1423
|
) {
|
|
1405
1424
|
this.#queryFailures.delete(owner.id);
|
|
1406
|
-
this.#queryFailures.set(
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1425
|
+
this.#queryFailures.set(
|
|
1426
|
+
owner.id,
|
|
1427
|
+
definedObject({
|
|
1428
|
+
id: owner.id,
|
|
1429
|
+
tables,
|
|
1430
|
+
code,
|
|
1431
|
+
sqliteCode: sqliteCode,
|
|
1432
|
+
atMs: this.#now(),
|
|
1433
|
+
}),
|
|
1434
|
+
);
|
|
1413
1435
|
for (const id of this.#queryFailures.keys()) {
|
|
1414
1436
|
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
1415
1437
|
break;
|
|
@@ -1798,22 +1820,18 @@ export class SyncClient {
|
|
|
1798
1820
|
}
|
|
1799
1821
|
return a.id.localeCompare(b.id);
|
|
1800
1822
|
});
|
|
1801
|
-
return {
|
|
1823
|
+
return definedObject({
|
|
1802
1824
|
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1803
1825
|
capturedAtMs,
|
|
1804
|
-
host: {
|
|
1826
|
+
host: definedObject({
|
|
1805
1827
|
kind: 'direct',
|
|
1806
1828
|
role: 'single',
|
|
1807
1829
|
connectivity,
|
|
1808
1830
|
realtime: this.#realtimeState,
|
|
1809
1831
|
realtimePolicy: this.#realtimePolicy,
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
...(this.#realtimeRetryDelayMs !== undefined
|
|
1814
|
-
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1815
|
-
: {}),
|
|
1816
|
-
},
|
|
1832
|
+
realtimeReasonCode: this.#realtimeReasonCode,
|
|
1833
|
+
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1834
|
+
}),
|
|
1817
1835
|
securityLifecycle: this.#securityLifecycle,
|
|
1818
1836
|
schema: {
|
|
1819
1837
|
currentVersion: this.#config.schema.version,
|
|
@@ -1837,25 +1855,21 @@ export class SyncClient {
|
|
|
1837
1855
|
),
|
|
1838
1856
|
subscriptionsTruncated:
|
|
1839
1857
|
allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
? { lastChange: this.#lastChange }
|
|
1843
|
-
: {}),
|
|
1858
|
+
lastRound: this.#lastRound,
|
|
1859
|
+
lastChange: this.#lastChange,
|
|
1844
1860
|
storage: this.#diagnosticStorage(),
|
|
1845
1861
|
queryFailures: [...this.#queryFailures.values()],
|
|
1846
|
-
};
|
|
1862
|
+
});
|
|
1847
1863
|
}
|
|
1848
1864
|
|
|
1849
1865
|
#diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
|
|
1850
1866
|
const lease = this.#leaseState;
|
|
1851
1867
|
if (lease?.errorCode !== undefined) {
|
|
1852
|
-
return {
|
|
1868
|
+
return definedObject({
|
|
1853
1869
|
state: 'stopped',
|
|
1854
1870
|
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
: {}),
|
|
1858
|
-
};
|
|
1871
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1872
|
+
});
|
|
1859
1873
|
}
|
|
1860
1874
|
if (lease?.expiresAtMs === undefined) return { state: 'none' };
|
|
1861
1875
|
return {
|
|
@@ -1874,18 +1888,16 @@ export class SyncClient {
|
|
|
1874
1888
|
);
|
|
1875
1889
|
const outboxBytes = Number(
|
|
1876
1890
|
this.#db.query(
|
|
1877
|
-
'SELECT COALESCE(SUM(LENGTH(operations)),
|
|
1891
|
+
'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
|
|
1878
1892
|
)[0]?.bytes ?? 0,
|
|
1879
1893
|
);
|
|
1880
1894
|
const outcome = this.#db.query(
|
|
1881
|
-
|
|
1882
|
-
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
1883
|
-
FROM _syncular_commit_outcomes`,
|
|
1895
|
+
'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
|
|
1884
1896
|
)[0];
|
|
1885
1897
|
const blobBytes = this.#hasBlobs
|
|
1886
1898
|
? Number(
|
|
1887
1899
|
this.#db.query(
|
|
1888
|
-
'SELECT COALESCE(SUM(byte_length),
|
|
1900
|
+
'SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs',
|
|
1889
1901
|
)[0]?.bytes ?? 0,
|
|
1890
1902
|
)
|
|
1891
1903
|
: 0;
|
|
@@ -1977,7 +1989,9 @@ export class SyncClient {
|
|
|
1977
1989
|
result = fn(batch);
|
|
1978
1990
|
if (
|
|
1979
1991
|
this.#db
|
|
1980
|
-
.query(
|
|
1992
|
+
.query(
|
|
1993
|
+
`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`,
|
|
1994
|
+
)
|
|
1981
1995
|
.some((row) => batch.hasTable(String(row.tbl)))
|
|
1982
1996
|
)
|
|
1983
1997
|
batch.outcomes();
|
|
@@ -2323,7 +2337,7 @@ export class SyncClient {
|
|
|
2323
2337
|
return this.#applyBatch(
|
|
2324
2338
|
(batch) => {
|
|
2325
2339
|
restoreFailedBases(this.#db, this.#schema);
|
|
2326
|
-
this.#db.exec(
|
|
2340
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
2327
2341
|
input.clientCommitId,
|
|
2328
2342
|
]);
|
|
2329
2343
|
const resolved = persistCommitOutcomeResolution(
|
|
@@ -2471,19 +2485,22 @@ export class SyncClient {
|
|
|
2471
2485
|
if (!sameIntent) {
|
|
2472
2486
|
throw new ClientSyncError(
|
|
2473
2487
|
'client.subscription_intent_mismatch',
|
|
2474
|
-
'
|
|
2488
|
+
'subscription id already names different table, scopes, or params',
|
|
2475
2489
|
);
|
|
2476
2490
|
}
|
|
2477
2491
|
return;
|
|
2478
2492
|
}
|
|
2479
|
-
saveSubscription(
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2493
|
+
saveSubscription(
|
|
2494
|
+
this.#db,
|
|
2495
|
+
definedObject({
|
|
2496
|
+
id: input.id,
|
|
2497
|
+
table: input.table,
|
|
2498
|
+
scopes: input.scopes,
|
|
2499
|
+
params: input.params,
|
|
2500
|
+
cursor: -1,
|
|
2501
|
+
status: 'active',
|
|
2502
|
+
}),
|
|
2503
|
+
);
|
|
2487
2504
|
this.#setSyncNeeded(true);
|
|
2488
2505
|
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2489
2506
|
this.#emitDiagnostics();
|
|
@@ -2526,7 +2543,7 @@ export class SyncClient {
|
|
|
2526
2543
|
const table = this.#table(base.table);
|
|
2527
2544
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
2528
2545
|
throw invalidRequest(
|
|
2529
|
-
|
|
2546
|
+
`${base.table}: unknown scope variable ${base.variable} (§4.8)`,
|
|
2530
2547
|
);
|
|
2531
2548
|
}
|
|
2532
2549
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
@@ -2549,14 +2566,17 @@ export class SyncClient {
|
|
|
2549
2566
|
// Re-entry cancels any deferred eviction for this sub id.
|
|
2550
2567
|
deletePendingEviction(this.#db, subId);
|
|
2551
2568
|
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
2552
|
-
saveSubscription(
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2569
|
+
saveSubscription(
|
|
2570
|
+
this.#db,
|
|
2571
|
+
definedObject({
|
|
2572
|
+
id: subId,
|
|
2573
|
+
table: base.table,
|
|
2574
|
+
scopes: unitScopes(base, unit),
|
|
2575
|
+
params: base.params,
|
|
2576
|
+
cursor: -1,
|
|
2577
|
+
status: 'active',
|
|
2578
|
+
}),
|
|
2579
|
+
);
|
|
2560
2580
|
});
|
|
2561
2581
|
this.#needsPull = true;
|
|
2562
2582
|
batch.window(baseKey, base.table, unit);
|
|
@@ -2732,14 +2752,12 @@ export class SyncClient {
|
|
|
2732
2752
|
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
2733
2753
|
const table = this.#table(mutation.table);
|
|
2734
2754
|
if (mutation.op === 'delete') {
|
|
2735
|
-
return {
|
|
2755
|
+
return definedObject({
|
|
2736
2756
|
table: mutation.table,
|
|
2737
2757
|
rowId: mutation.rowId,
|
|
2738
2758
|
op: 'delete',
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
: {}),
|
|
2742
|
-
};
|
|
2759
|
+
baseVersion: mutation.baseVersion,
|
|
2760
|
+
});
|
|
2743
2761
|
}
|
|
2744
2762
|
const json: Record<string, JsonRowValue> = {};
|
|
2745
2763
|
if (mutation.op === 'patch') {
|
|
@@ -2752,7 +2770,7 @@ export class SyncClient {
|
|
|
2752
2770
|
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2753
2771
|
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
2754
2772
|
const local = this.#db.query(
|
|
2755
|
-
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
2773
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
2756
2774
|
[pkValue],
|
|
2757
2775
|
)[0];
|
|
2758
2776
|
if (!local)
|
|
@@ -2813,15 +2831,13 @@ export class SyncClient {
|
|
|
2813
2831
|
});
|
|
2814
2832
|
}
|
|
2815
2833
|
const pkValue = requireRowId(json[table.primaryKey]);
|
|
2816
|
-
return {
|
|
2834
|
+
return definedObject({
|
|
2817
2835
|
table: mutation.table,
|
|
2818
2836
|
rowId: pkValue,
|
|
2819
2837
|
op: 'upsert',
|
|
2820
|
-
|
|
2821
|
-
? { baseVersion: mutation.baseVersion }
|
|
2822
|
-
: {}),
|
|
2838
|
+
baseVersion: mutation.baseVersion,
|
|
2823
2839
|
values: json,
|
|
2824
|
-
};
|
|
2840
|
+
});
|
|
2825
2841
|
});
|
|
2826
2842
|
this.#applyBatch((batch) => {
|
|
2827
2843
|
this.#db.transaction(() => {
|
|
@@ -2847,10 +2863,7 @@ export class SyncClient {
|
|
|
2847
2863
|
continue;
|
|
2848
2864
|
}
|
|
2849
2865
|
this.#db.exec(
|
|
2850
|
-
|
|
2851
|
-
SELECT ?, ? WHERE EXISTS (
|
|
2852
|
-
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
2853
|
-
)`,
|
|
2866
|
+
'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
|
|
2854
2867
|
[clientCommitId, blobId, blobId],
|
|
2855
2868
|
);
|
|
2856
2869
|
}
|
|
@@ -2956,23 +2969,19 @@ export class SyncClient {
|
|
|
2956
2969
|
// mirror row is touched and unconditionally.
|
|
2957
2970
|
this.#dropPreviousVersion();
|
|
2958
2971
|
|
|
2959
|
-
const
|
|
2972
|
+
const priorRejections = [...this.#rejections];
|
|
2973
|
+
const priorConflicts = [...this.#conflicts];
|
|
2960
2974
|
try {
|
|
2961
2975
|
return this.#applyBatch((batch) => {
|
|
2962
2976
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2963
2977
|
restoreFailedBases(this.#db, this.#schema);
|
|
2964
|
-
|
|
2978
|
+
this.#dropRetainedRows(batch, (table, values) =>
|
|
2965
2979
|
(targetsByTable.get(table) ?? []).some((target) =>
|
|
2966
2980
|
target.selectors.every((selector) =>
|
|
2967
2981
|
selector.values.includes(String(values[selector.column])),
|
|
2968
2982
|
),
|
|
2969
2983
|
),
|
|
2970
2984
|
);
|
|
2971
|
-
for (const commit of droppedRetained) {
|
|
2972
|
-
for (const operation of commit.operations)
|
|
2973
|
-
batch.table(operation.table);
|
|
2974
|
-
batch.outcomes();
|
|
2975
|
-
}
|
|
2976
2985
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2977
2986
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2978
2987
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -3038,7 +3047,7 @@ export class SyncClient {
|
|
|
3038
3047
|
for (let offset = 0; offset < values.length; offset += 400) {
|
|
3039
3048
|
const chunk = values.slice(offset, offset + 400);
|
|
3040
3049
|
this.#db.exec(
|
|
3041
|
-
`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN
|
|
3050
|
+
`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`,
|
|
3042
3051
|
chunk,
|
|
3043
3052
|
);
|
|
3044
3053
|
}
|
|
@@ -3086,7 +3095,8 @@ export class SyncClient {
|
|
|
3086
3095
|
} catch (error) {
|
|
3087
3096
|
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
3088
3097
|
// in-memory rejection cache before surfacing the storage failure.
|
|
3089
|
-
this.#rejections
|
|
3098
|
+
this.#rejections = priorRejections;
|
|
3099
|
+
this.#conflicts = priorConflicts;
|
|
3090
3100
|
throw error;
|
|
3091
3101
|
}
|
|
3092
3102
|
}
|
|
@@ -3131,6 +3141,7 @@ export class SyncClient {
|
|
|
3131
3141
|
this.#upgrading = true;
|
|
3132
3142
|
this.#needsPull = true;
|
|
3133
3143
|
batch.status();
|
|
3144
|
+
this.#discardAcknowledgedRows();
|
|
3134
3145
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
3135
3146
|
resetSubscriptionsForBump(this.#db);
|
|
3136
3147
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -3192,7 +3203,7 @@ export class SyncClient {
|
|
|
3192
3203
|
params.push(...selector.values);
|
|
3193
3204
|
}
|
|
3194
3205
|
for (const row of this.#db.query(
|
|
3195
|
-
`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)
|
|
3206
|
+
`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
3196
3207
|
params,
|
|
3197
3208
|
)) {
|
|
3198
3209
|
if (typeof row.id === 'string') ids.add(row.id);
|
|
@@ -3251,7 +3262,7 @@ export class SyncClient {
|
|
|
3251
3262
|
// Pin before the first encryption await: mutations can append while a
|
|
3252
3263
|
// round is encoding, and belong to the next request.
|
|
3253
3264
|
const bounds = this.#db.query(
|
|
3254
|
-
'SELECT COUNT(*)
|
|
3265
|
+
'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
|
|
3255
3266
|
)[0]!;
|
|
3256
3267
|
const pendingCount = bounds.count as number;
|
|
3257
3268
|
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
@@ -3288,7 +3299,7 @@ export class SyncClient {
|
|
|
3288
3299
|
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
3289
3300
|
if (selector === undefined) return undefined;
|
|
3290
3301
|
const row = this.#db.query(
|
|
3291
|
-
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3302
|
+
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
3292
3303
|
[rowId],
|
|
3293
3304
|
)[0];
|
|
3294
3305
|
const value = row?.value;
|
|
@@ -3401,16 +3412,16 @@ export class SyncClient {
|
|
|
3401
3412
|
const error = classifySqliteFailure(caught).error;
|
|
3402
3413
|
const completedAtMs = this.#now();
|
|
3403
3414
|
const code = (error as { code?: unknown }).code;
|
|
3404
|
-
this.#progress.update(
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3415
|
+
this.#progress.update(
|
|
3416
|
+
definedObject({
|
|
3417
|
+
state: 'failed',
|
|
3418
|
+
errorCode:
|
|
3419
|
+
typeof code === 'string'
|
|
3420
|
+
? this.#diagnosticCode(code)
|
|
3421
|
+
: 'client.unknown_failure',
|
|
3422
|
+
retryDelayMs: this.#roundRetryDelayMs,
|
|
3423
|
+
}),
|
|
3424
|
+
);
|
|
3414
3425
|
this.#lastRound = {
|
|
3415
3426
|
status: 'failed',
|
|
3416
3427
|
startedAtMs,
|
|
@@ -3515,12 +3526,12 @@ export class SyncClient {
|
|
|
3515
3526
|
);
|
|
3516
3527
|
const limits = this.#config.limits;
|
|
3517
3528
|
const frames: RequestFrame[] = [
|
|
3518
|
-
{
|
|
3529
|
+
definedObject({
|
|
3519
3530
|
type: 'REQ_HEADER',
|
|
3520
3531
|
clientId: this.#clientId,
|
|
3521
3532
|
schemaVersion: this.#schema.version,
|
|
3522
|
-
|
|
3523
|
-
},
|
|
3533
|
+
logEpoch: logEpoch,
|
|
3534
|
+
}),
|
|
3524
3535
|
...pushFrames,
|
|
3525
3536
|
{
|
|
3526
3537
|
type: 'PULL_HEADER',
|
|
@@ -3530,17 +3541,16 @@ export class SyncClient {
|
|
|
3530
3541
|
accept: this.#acceptMask(),
|
|
3531
3542
|
},
|
|
3532
3543
|
...subs.map(
|
|
3533
|
-
(sub): RequestFrame =>
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
}),
|
|
3544
|
+
(sub): RequestFrame =>
|
|
3545
|
+
definedObject({
|
|
3546
|
+
type: 'SUBSCRIPTION',
|
|
3547
|
+
id: sub.id,
|
|
3548
|
+
table: sub.table,
|
|
3549
|
+
scopes: sub.scopes,
|
|
3550
|
+
params: sub.params,
|
|
3551
|
+
cursor: sub.cursor,
|
|
3552
|
+
bootstrapState: sub.bootstrapState,
|
|
3553
|
+
}),
|
|
3544
3554
|
),
|
|
3545
3555
|
];
|
|
3546
3556
|
const requestBytes = encodeMessage({
|
|
@@ -4074,12 +4084,10 @@ export class SyncClient {
|
|
|
4074
4084
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
4075
4085
|
// when the APP updates (recreating the client with a new generated
|
|
4076
4086
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
4077
|
-
const schemaFloor: SchemaFloor = {
|
|
4087
|
+
const schemaFloor: SchemaFloor = definedObject({
|
|
4078
4088
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
: {}),
|
|
4082
|
-
};
|
|
4089
|
+
latestSchemaVersion: header.latestSchemaVersion,
|
|
4090
|
+
});
|
|
4083
4091
|
this.#setSchemaFloor(schemaFloor);
|
|
4084
4092
|
return {
|
|
4085
4093
|
...summary,
|
|
@@ -4141,6 +4149,7 @@ export class SyncClient {
|
|
|
4141
4149
|
index += 1;
|
|
4142
4150
|
}
|
|
4143
4151
|
}
|
|
4152
|
+
const previousNeedsPull = this.#needsPull;
|
|
4144
4153
|
const conflictCount = this.#conflicts.length;
|
|
4145
4154
|
const rejectionCount = this.#rejections.length;
|
|
4146
4155
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -4173,11 +4182,12 @@ export class SyncClient {
|
|
|
4173
4182
|
},
|
|
4174
4183
|
() => this.#statusSnapshot(outboxCount),
|
|
4175
4184
|
(cause) => {
|
|
4185
|
+
this.#needsPull = previousNeedsPull;
|
|
4176
4186
|
this.#conflicts.length = conflictCount;
|
|
4177
4187
|
this.#rejections.length = rejectionCount;
|
|
4178
4188
|
const error = new ClientSyncError(
|
|
4179
4189
|
'client.outcome_persistence_failed',
|
|
4180
|
-
'
|
|
4190
|
+
'outcome persistence failed',
|
|
4181
4191
|
);
|
|
4182
4192
|
error.cause = cause;
|
|
4183
4193
|
throw error;
|
|
@@ -4475,6 +4485,18 @@ export class SyncClient {
|
|
|
4475
4485
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
4476
4486
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
4477
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);
|
|
4478
4500
|
recordCommitOutcome(this.#db, {
|
|
4479
4501
|
clientCommitId: frame.clientCommitId,
|
|
4480
4502
|
status: frame.status,
|
|
@@ -4507,7 +4529,7 @@ export class SyncClient {
|
|
|
4507
4529
|
for (const result of frame.results) {
|
|
4508
4530
|
const operation = commit.operations[result.opIndex];
|
|
4509
4531
|
if (result.status === 'conflict') {
|
|
4510
|
-
const conflict: ConflictRecord = {
|
|
4532
|
+
const conflict: ConflictRecord = definedObject({
|
|
4511
4533
|
clientCommitId: frame.clientCommitId,
|
|
4512
4534
|
opIndex: result.opIndex,
|
|
4513
4535
|
table: operation?.table ?? '',
|
|
@@ -4520,23 +4542,23 @@ export class SyncClient {
|
|
|
4520
4542
|
operation?.table,
|
|
4521
4543
|
result.conflictColumns,
|
|
4522
4544
|
),
|
|
4523
|
-
|
|
4524
|
-
};
|
|
4545
|
+
operation: operation,
|
|
4546
|
+
});
|
|
4525
4547
|
this.#conflicts.push(conflict);
|
|
4526
4548
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
4527
4549
|
batch.conflicts();
|
|
4528
4550
|
summary.conflicts.push(conflict);
|
|
4529
4551
|
} else if (result.status === 'error') {
|
|
4530
4552
|
const details = rejectionDetails?.get(result.opIndex);
|
|
4531
|
-
const rejection: RejectionRecord = {
|
|
4553
|
+
const rejection: RejectionRecord = definedObject({
|
|
4532
4554
|
clientCommitId: frame.clientCommitId,
|
|
4533
4555
|
opIndex: result.opIndex,
|
|
4534
4556
|
code: result.code,
|
|
4535
4557
|
message: result.message,
|
|
4536
4558
|
retryable: result.retryable,
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
};
|
|
4559
|
+
details: details,
|
|
4560
|
+
operation: operation,
|
|
4561
|
+
});
|
|
4540
4562
|
this.#rejections.push(rejection);
|
|
4541
4563
|
outcomeResults.push({ status: 'error', rejection });
|
|
4542
4564
|
batch.rejections();
|
|
@@ -4574,7 +4596,7 @@ export class SyncClient {
|
|
|
4574
4596
|
#outboxCommitExists(clientCommitId: string): boolean {
|
|
4575
4597
|
return (
|
|
4576
4598
|
this.#db.query(
|
|
4577
|
-
'SELECT 1 FROM _syncular_outbox WHERE client_commit_id
|
|
4599
|
+
'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
|
|
4578
4600
|
[clientCommitId],
|
|
4579
4601
|
).length > 0
|
|
4580
4602
|
);
|
|
@@ -4627,7 +4649,9 @@ export class SyncClient {
|
|
|
4627
4649
|
(fn) =>
|
|
4628
4650
|
this.#applyBatch((batch) => {
|
|
4629
4651
|
this.#recordCommitChanges(batch, frame);
|
|
4630
|
-
|
|
4652
|
+
const result = fn();
|
|
4653
|
+
this.#replayOutbox(result);
|
|
4654
|
+
return result;
|
|
4631
4655
|
}),
|
|
4632
4656
|
);
|
|
4633
4657
|
summary.commitsApplied += 1;
|
|
@@ -4660,9 +4684,7 @@ export class SyncClient {
|
|
|
4660
4684
|
);
|
|
4661
4685
|
if (mappings.length === 0) return false;
|
|
4662
4686
|
const row = this.#db.query(
|
|
4663
|
-
`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
4664
|
-
FROM ${quoteIdent(table.name)}
|
|
4665
|
-
WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4687
|
+
`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
4666
4688
|
[rowId],
|
|
4667
4689
|
)[0];
|
|
4668
4690
|
if (row === undefined) return false;
|
|
@@ -4680,23 +4702,12 @@ export class SyncClient {
|
|
|
4680
4702
|
|
|
4681
4703
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
4682
4704
|
#scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
|
|
4683
|
-
const
|
|
4684
|
-
if (entries.length === 0) return false;
|
|
4685
|
-
const clauses: string[] = [];
|
|
4686
|
-
const params: string[] = [];
|
|
4687
|
-
for (const [variable, values] of entries) {
|
|
4688
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
4689
|
-
if (column === undefined || values.length === 0) return false;
|
|
4690
|
-
clauses.push(
|
|
4691
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
4692
|
-
);
|
|
4693
|
-
params.push(...values);
|
|
4694
|
-
}
|
|
4705
|
+
const scope = localScopePredicate(table, effective);
|
|
4695
4706
|
return (
|
|
4707
|
+
scope !== undefined &&
|
|
4696
4708
|
this.#db.query(
|
|
4697
|
-
`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
4698
|
-
|
|
4699
|
-
params,
|
|
4709
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`,
|
|
4710
|
+
scope.params,
|
|
4700
4711
|
).length > 0
|
|
4701
4712
|
);
|
|
4702
4713
|
}
|
|
@@ -4733,18 +4744,19 @@ export class SyncClient {
|
|
|
4733
4744
|
) {
|
|
4734
4745
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4735
4746
|
this.#applyBatch((batch) => {
|
|
4736
|
-
saveSubscription(
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
:
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4747
|
+
saveSubscription(
|
|
4748
|
+
this.#db,
|
|
4749
|
+
definedObject({
|
|
4750
|
+
id: sub.id,
|
|
4751
|
+
table: sub.table,
|
|
4752
|
+
scopes: sub.scopes,
|
|
4753
|
+
params: sub.params,
|
|
4754
|
+
cursor: sub.cursor,
|
|
4755
|
+
effectiveScopes: sub.effectiveScopes,
|
|
4756
|
+
status: 'failed',
|
|
4757
|
+
reasonCode: 'sync.scope_revoked',
|
|
4758
|
+
}),
|
|
4759
|
+
);
|
|
4748
4760
|
if (registered !== undefined) {
|
|
4749
4761
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4750
4762
|
}
|
|
@@ -4802,7 +4814,7 @@ export class SyncClient {
|
|
|
4802
4814
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
4803
4815
|
throw new ClientSyncError(
|
|
4804
4816
|
'sync.segment_expired',
|
|
4805
|
-
`
|
|
4817
|
+
`segment download grant expired; pull again (§5.4)`,
|
|
4806
4818
|
true,
|
|
4807
4819
|
);
|
|
4808
4820
|
}
|
|
@@ -4847,16 +4859,47 @@ export class SyncClient {
|
|
|
4847
4859
|
wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
4848
4860
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4849
4861
|
this.#applyBatch((batch) => {
|
|
4850
|
-
saveSubscription(
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4862
|
+
saveSubscription(
|
|
4863
|
+
this.#db,
|
|
4864
|
+
definedObject({
|
|
4865
|
+
id: sub.id,
|
|
4866
|
+
table: sub.table,
|
|
4867
|
+
scopes: sub.scopes,
|
|
4868
|
+
params: sub.params,
|
|
4869
|
+
cursor: nextCursor,
|
|
4870
|
+
bootstrapState: bootstrapState,
|
|
4871
|
+
effectiveScopes: start.effectiveScopes,
|
|
4872
|
+
status: 'active',
|
|
4873
|
+
}),
|
|
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
|
+
}
|
|
4860
4903
|
if (completed && registered !== undefined) {
|
|
4861
4904
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
4862
4905
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -4871,18 +4914,19 @@ export class SyncClient {
|
|
|
4871
4914
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
4872
4915
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4873
4916
|
this.#applyBatch((batch) => {
|
|
4874
|
-
saveSubscription(
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
:
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4917
|
+
saveSubscription(
|
|
4918
|
+
this.#db,
|
|
4919
|
+
definedObject({
|
|
4920
|
+
id: sub.id,
|
|
4921
|
+
table: sub.table,
|
|
4922
|
+
scopes: sub.scopes,
|
|
4923
|
+
params: sub.params,
|
|
4924
|
+
cursor: -1,
|
|
4925
|
+
effectiveScopes: sub.effectiveScopes,
|
|
4926
|
+
status: 'active',
|
|
4927
|
+
reasonCode: start.reasonCode,
|
|
4928
|
+
}),
|
|
4929
|
+
);
|
|
4886
4930
|
if (registered !== undefined) {
|
|
4887
4931
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4888
4932
|
}
|
|
@@ -4904,21 +4948,12 @@ export class SyncClient {
|
|
|
4904
4948
|
) {
|
|
4905
4949
|
try {
|
|
4906
4950
|
restoreFailedBases(this.#db, this.#schema);
|
|
4907
|
-
|
|
4908
|
-
|
|
4951
|
+
this.#dropRetainedRows(
|
|
4952
|
+
batch,
|
|
4909
4953
|
(name, values) =>
|
|
4910
4954
|
name === table.name &&
|
|
4911
|
-
|
|
4912
|
-
allowed.includes(
|
|
4913
|
-
String(values[table.scopeColumnByVariable.get(variable)!]),
|
|
4914
|
-
),
|
|
4915
|
-
),
|
|
4955
|
+
matchesLocalScopes(table, lastEffective, values),
|
|
4916
4956
|
);
|
|
4917
|
-
for (const commit of droppedRetained) {
|
|
4918
|
-
for (const operation of commit.operations)
|
|
4919
|
-
batch.table(operation.table);
|
|
4920
|
-
batch.outcomes();
|
|
4921
|
-
}
|
|
4922
4957
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4923
4958
|
batch.scopeMap(table, lastEffective);
|
|
4924
4959
|
const pendingById = new Map(
|
|
@@ -4976,18 +5011,19 @@ export class SyncClient {
|
|
|
4976
5011
|
}
|
|
4977
5012
|
}
|
|
4978
5013
|
}
|
|
4979
|
-
saveSubscription(
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
:
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
5014
|
+
saveSubscription(
|
|
5015
|
+
this.#db,
|
|
5016
|
+
definedObject({
|
|
5017
|
+
id: sub.id,
|
|
5018
|
+
table: sub.table,
|
|
5019
|
+
scopes: sub.scopes,
|
|
5020
|
+
params: sub.params,
|
|
5021
|
+
cursor: nextCursor,
|
|
5022
|
+
effectiveScopes: lastEffective,
|
|
5023
|
+
status: failed ? 'failed' : 'revoked',
|
|
5024
|
+
reasonCode: start.reasonCode,
|
|
5025
|
+
}),
|
|
5026
|
+
);
|
|
4991
5027
|
if (registered !== undefined) {
|
|
4992
5028
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4993
5029
|
}
|
|
@@ -5010,7 +5046,7 @@ export class SyncClient {
|
|
|
5010
5046
|
quoteIdent(SYNC_VERSION_COLUMN),
|
|
5011
5047
|
];
|
|
5012
5048
|
const row = this.#db.query(
|
|
5013
|
-
`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5049
|
+
`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5014
5050
|
[operation.rowId],
|
|
5015
5051
|
)[0];
|
|
5016
5052
|
if (row === undefined) return { opIndex, existed: false };
|
|
@@ -5074,7 +5110,7 @@ export class SyncClient {
|
|
|
5074
5110
|
const table = this.#schema.tables.get(operation.table);
|
|
5075
5111
|
if (table === undefined) continue;
|
|
5076
5112
|
const row = this.#db.query(
|
|
5077
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5113
|
+
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5078
5114
|
[operation.rowId],
|
|
5079
5115
|
)[0];
|
|
5080
5116
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
@@ -5130,6 +5166,7 @@ export class SyncClient {
|
|
|
5130
5166
|
#applyOperationsLocally(
|
|
5131
5167
|
operations: readonly OutboxOperation[],
|
|
5132
5168
|
batch?: ChangeAccumulator,
|
|
5169
|
+
replay = false,
|
|
5133
5170
|
): void {
|
|
5134
5171
|
for (const op of operations) {
|
|
5135
5172
|
const table = this.#table(op.table);
|
|
@@ -5149,7 +5186,7 @@ export class SyncClient {
|
|
|
5149
5186
|
const opValues = op.values ?? {};
|
|
5150
5187
|
const full = table.columns.every((column) => column.name in opValues);
|
|
5151
5188
|
const local = this.#db.query(
|
|
5152
|
-
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5189
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5153
5190
|
[op.rowId],
|
|
5154
5191
|
)[0];
|
|
5155
5192
|
if (local === undefined && !full) {
|
|
@@ -5180,23 +5217,72 @@ export class SyncClient {
|
|
|
5180
5217
|
local === undefined
|
|
5181
5218
|
? OPTIMISTIC_VERSION
|
|
5182
5219
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5220
|
+
if (
|
|
5221
|
+
replay &&
|
|
5222
|
+
table.indexes.some((index) => index.unique) &&
|
|
5223
|
+
uniqueConflicts(
|
|
5224
|
+
this.#db,
|
|
5225
|
+
table,
|
|
5226
|
+
Object.fromEntries(
|
|
5227
|
+
table.columns.map((column, index) => [
|
|
5228
|
+
column.name,
|
|
5229
|
+
values[index] ?? null,
|
|
5230
|
+
]),
|
|
5231
|
+
),
|
|
5232
|
+
).length
|
|
5233
|
+
)
|
|
5234
|
+
continue;
|
|
5183
5235
|
upsertLocalRow(this.#db, table, values, version, false);
|
|
5184
5236
|
}
|
|
5185
5237
|
}
|
|
5186
5238
|
|
|
5187
5239
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
5188
|
-
#
|
|
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);
|
|
5189
5267
|
const pending = [
|
|
5190
|
-
...failedOverlayCommits(this.#db),
|
|
5191
|
-
...listOutbox(this.#db),
|
|
5268
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
5269
|
+
...listOutbox(this.#db, rowKeys),
|
|
5192
5270
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5193
|
-
if (pending.length === 0) return;
|
|
5271
|
+
if (pending.length === 0 && acknowledged.length === 0) return;
|
|
5194
5272
|
this.#applyBatch((batch) => {
|
|
5195
5273
|
this.#db.transaction(() => {
|
|
5196
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
5197
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
5198
|
-
for (const commit of pending)
|
|
5199
|
-
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
|
+
);
|
|
5200
5286
|
});
|
|
5201
5287
|
});
|
|
5202
5288
|
}
|
|
@@ -5211,17 +5297,14 @@ export class SyncClient {
|
|
|
5211
5297
|
#table(name: string): CompiledClientTable {
|
|
5212
5298
|
const table = this.#schema.tables.get(name);
|
|
5213
5299
|
if (table === undefined) {
|
|
5214
|
-
throw new ClientSyncError(
|
|
5215
|
-
'sync.unknown_table',
|
|
5216
|
-
`unknown local table ${JSON.stringify(name)}`,
|
|
5217
|
-
);
|
|
5300
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
5218
5301
|
}
|
|
5219
5302
|
return table;
|
|
5220
5303
|
}
|
|
5221
5304
|
|
|
5222
5305
|
#requireStarted(): void {
|
|
5223
5306
|
if (!this.#started) {
|
|
5224
|
-
throw invalidRequest('SyncClient.start()
|
|
5307
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
5225
5308
|
}
|
|
5226
5309
|
}
|
|
5227
5310
|
|
|
@@ -5230,12 +5313,27 @@ export class SyncClient {
|
|
|
5230
5313
|
if (this.#securityLifecycle === 'preflight') {
|
|
5231
5314
|
throw new ClientSyncError(
|
|
5232
5315
|
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
5233
|
-
'
|
|
5316
|
+
'security preflight must complete before accessing protected data; call activateSecurity',
|
|
5234
5317
|
);
|
|
5235
5318
|
}
|
|
5236
5319
|
}
|
|
5237
5320
|
}
|
|
5238
5321
|
|
|
5322
|
+
type DefinedObject<T> = {
|
|
5323
|
+
[K in keyof T as undefined extends T[K] ? never : K]: T[K];
|
|
5324
|
+
} & {
|
|
5325
|
+
[K in keyof T as undefined extends T[K] ? K : never]?: Exclude<
|
|
5326
|
+
T[K],
|
|
5327
|
+
undefined
|
|
5328
|
+
>;
|
|
5329
|
+
};
|
|
5330
|
+
function definedObject<const T extends object>(record: T): DefinedObject<T> {
|
|
5331
|
+
for (const key of Object.keys(record) as (keyof T)[]) {
|
|
5332
|
+
if (record[key] === undefined) delete record[key];
|
|
5333
|
+
}
|
|
5334
|
+
return record as DefinedObject<T>;
|
|
5335
|
+
}
|
|
5336
|
+
|
|
5239
5337
|
function requireRowId(value: unknown): string {
|
|
5240
5338
|
if (typeof value !== 'string' || !value.length)
|
|
5241
5339
|
throw invalidRequest('a mutation requires a non-empty string primary key');
|