@syncular/client 0.30.9 → 0.30.11
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 +14 -2
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +128 -126
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +87 -15
- package/dist/outbox.js +11 -21
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +18 -27
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -85
- 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 +5 -4
- package/src/apply.ts +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +238 -177
- package/src/failed-overlay.ts +164 -17
- package/src/outbox.ts +11 -21
- package/src/outcomes.ts +35 -31
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +91 -96
- 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
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
failedOverlayCommits,
|
|
4
4
|
retainFailedRows,
|
|
5
5
|
restoreFailedBases,
|
|
6
|
+
uniqueConflicts,
|
|
6
7
|
} from './failed-overlay';
|
|
7
8
|
import {
|
|
8
9
|
ProgressEmitter,
|
|
@@ -219,6 +220,7 @@ import {
|
|
|
219
220
|
getSubscription,
|
|
220
221
|
loadSubscriptions,
|
|
221
222
|
pruneUnknownSubscriptions,
|
|
223
|
+
saveSubscriptionScopeSchema,
|
|
222
224
|
resetSubscriptionsForBump,
|
|
223
225
|
type SubscriptionRecord,
|
|
224
226
|
saveSubscription,
|
|
@@ -947,7 +949,7 @@ export class SyncClient {
|
|
|
947
949
|
const subscriptions = pruneUnknownSubscriptions(
|
|
948
950
|
this.#db,
|
|
949
951
|
loadSubscriptions(this.#db),
|
|
950
|
-
|
|
952
|
+
this.#schema,
|
|
951
953
|
);
|
|
952
954
|
this.#started = true;
|
|
953
955
|
// A persisted active subscription needs one catch-up round on every open:
|
|
@@ -1000,6 +1002,7 @@ export class SyncClient {
|
|
|
1000
1002
|
String(this.#schema.version),
|
|
1001
1003
|
);
|
|
1002
1004
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1005
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1003
1006
|
});
|
|
1004
1007
|
return;
|
|
1005
1008
|
}
|
|
@@ -1011,6 +1014,7 @@ export class SyncClient {
|
|
|
1011
1014
|
this.#db.transaction(() => {
|
|
1012
1015
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
1013
1016
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1017
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1014
1018
|
});
|
|
1015
1019
|
return;
|
|
1016
1020
|
}
|
|
@@ -1050,6 +1054,12 @@ export class SyncClient {
|
|
|
1050
1054
|
),
|
|
1051
1055
|
);
|
|
1052
1056
|
}
|
|
1057
|
+
pruneUnknownSubscriptions(
|
|
1058
|
+
this.#db,
|
|
1059
|
+
loadSubscriptions(this.#db),
|
|
1060
|
+
this.#schema,
|
|
1061
|
+
true,
|
|
1062
|
+
);
|
|
1053
1063
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
1054
1064
|
resetSubscriptionsForBump(this.#db);
|
|
1055
1065
|
setMeta(
|
|
@@ -1058,6 +1068,7 @@ export class SyncClient {
|
|
|
1058
1068
|
String(this.#schema.version),
|
|
1059
1069
|
);
|
|
1060
1070
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
1071
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
1061
1072
|
});
|
|
1062
1073
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
1063
1074
|
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
@@ -1403,13 +1414,16 @@ export class SyncClient {
|
|
|
1403
1414
|
previous.tables.join('\0') !== tables.join('\0')
|
|
1404
1415
|
) {
|
|
1405
1416
|
this.#queryFailures.delete(owner.id);
|
|
1406
|
-
this.#queryFailures.set(
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1417
|
+
this.#queryFailures.set(
|
|
1418
|
+
owner.id,
|
|
1419
|
+
definedObject({
|
|
1420
|
+
id: owner.id,
|
|
1421
|
+
tables,
|
|
1422
|
+
code,
|
|
1423
|
+
sqliteCode: sqliteCode,
|
|
1424
|
+
atMs: this.#now(),
|
|
1425
|
+
}),
|
|
1426
|
+
);
|
|
1413
1427
|
for (const id of this.#queryFailures.keys()) {
|
|
1414
1428
|
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
1415
1429
|
break;
|
|
@@ -1798,22 +1812,18 @@ export class SyncClient {
|
|
|
1798
1812
|
}
|
|
1799
1813
|
return a.id.localeCompare(b.id);
|
|
1800
1814
|
});
|
|
1801
|
-
return {
|
|
1815
|
+
return definedObject({
|
|
1802
1816
|
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1803
1817
|
capturedAtMs,
|
|
1804
|
-
host: {
|
|
1818
|
+
host: definedObject({
|
|
1805
1819
|
kind: 'direct',
|
|
1806
1820
|
role: 'single',
|
|
1807
1821
|
connectivity,
|
|
1808
1822
|
realtime: this.#realtimeState,
|
|
1809
1823
|
realtimePolicy: this.#realtimePolicy,
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
...(this.#realtimeRetryDelayMs !== undefined
|
|
1814
|
-
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1815
|
-
: {}),
|
|
1816
|
-
},
|
|
1824
|
+
realtimeReasonCode: this.#realtimeReasonCode,
|
|
1825
|
+
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1826
|
+
}),
|
|
1817
1827
|
securityLifecycle: this.#securityLifecycle,
|
|
1818
1828
|
schema: {
|
|
1819
1829
|
currentVersion: this.#config.schema.version,
|
|
@@ -1837,25 +1847,21 @@ export class SyncClient {
|
|
|
1837
1847
|
),
|
|
1838
1848
|
subscriptionsTruncated:
|
|
1839
1849
|
allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
? { lastChange: this.#lastChange }
|
|
1843
|
-
: {}),
|
|
1850
|
+
lastRound: this.#lastRound,
|
|
1851
|
+
lastChange: this.#lastChange,
|
|
1844
1852
|
storage: this.#diagnosticStorage(),
|
|
1845
1853
|
queryFailures: [...this.#queryFailures.values()],
|
|
1846
|
-
};
|
|
1854
|
+
});
|
|
1847
1855
|
}
|
|
1848
1856
|
|
|
1849
1857
|
#diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
|
|
1850
1858
|
const lease = this.#leaseState;
|
|
1851
1859
|
if (lease?.errorCode !== undefined) {
|
|
1852
|
-
return {
|
|
1860
|
+
return definedObject({
|
|
1853
1861
|
state: 'stopped',
|
|
1854
1862
|
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
: {}),
|
|
1858
|
-
};
|
|
1863
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1864
|
+
});
|
|
1859
1865
|
}
|
|
1860
1866
|
if (lease?.expiresAtMs === undefined) return { state: 'none' };
|
|
1861
1867
|
return {
|
|
@@ -1874,18 +1880,16 @@ export class SyncClient {
|
|
|
1874
1880
|
);
|
|
1875
1881
|
const outboxBytes = Number(
|
|
1876
1882
|
this.#db.query(
|
|
1877
|
-
'SELECT COALESCE(SUM(LENGTH(operations)),
|
|
1883
|
+
'SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox',
|
|
1878
1884
|
)[0]?.bytes ?? 0,
|
|
1879
1885
|
);
|
|
1880
1886
|
const outcome = this.#db.query(
|
|
1881
|
-
|
|
1882
|
-
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
1883
|
-
FROM _syncular_commit_outcomes`,
|
|
1887
|
+
'SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes',
|
|
1884
1888
|
)[0];
|
|
1885
1889
|
const blobBytes = this.#hasBlobs
|
|
1886
1890
|
? Number(
|
|
1887
1891
|
this.#db.query(
|
|
1888
|
-
'SELECT COALESCE(SUM(byte_length),
|
|
1892
|
+
'SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs',
|
|
1889
1893
|
)[0]?.bytes ?? 0,
|
|
1890
1894
|
)
|
|
1891
1895
|
: 0;
|
|
@@ -2323,7 +2327,7 @@ export class SyncClient {
|
|
|
2323
2327
|
return this.#applyBatch(
|
|
2324
2328
|
(batch) => {
|
|
2325
2329
|
restoreFailedBases(this.#db, this.#schema);
|
|
2326
|
-
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id
|
|
2330
|
+
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
|
|
2327
2331
|
input.clientCommitId,
|
|
2328
2332
|
]);
|
|
2329
2333
|
const resolved = persistCommitOutcomeResolution(
|
|
@@ -2476,14 +2480,17 @@ export class SyncClient {
|
|
|
2476
2480
|
}
|
|
2477
2481
|
return;
|
|
2478
2482
|
}
|
|
2479
|
-
saveSubscription(
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2483
|
+
saveSubscription(
|
|
2484
|
+
this.#db,
|
|
2485
|
+
definedObject({
|
|
2486
|
+
id: input.id,
|
|
2487
|
+
table: input.table,
|
|
2488
|
+
scopes: input.scopes,
|
|
2489
|
+
params: input.params,
|
|
2490
|
+
cursor: -1,
|
|
2491
|
+
status: 'active',
|
|
2492
|
+
}),
|
|
2493
|
+
);
|
|
2487
2494
|
this.#setSyncNeeded(true);
|
|
2488
2495
|
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2489
2496
|
this.#emitDiagnostics();
|
|
@@ -2549,14 +2556,17 @@ export class SyncClient {
|
|
|
2549
2556
|
// Re-entry cancels any deferred eviction for this sub id.
|
|
2550
2557
|
deletePendingEviction(this.#db, subId);
|
|
2551
2558
|
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
2552
|
-
saveSubscription(
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2559
|
+
saveSubscription(
|
|
2560
|
+
this.#db,
|
|
2561
|
+
definedObject({
|
|
2562
|
+
id: subId,
|
|
2563
|
+
table: base.table,
|
|
2564
|
+
scopes: unitScopes(base, unit),
|
|
2565
|
+
params: base.params,
|
|
2566
|
+
cursor: -1,
|
|
2567
|
+
status: 'active',
|
|
2568
|
+
}),
|
|
2569
|
+
);
|
|
2560
2570
|
});
|
|
2561
2571
|
this.#needsPull = true;
|
|
2562
2572
|
batch.window(baseKey, base.table, unit);
|
|
@@ -2732,14 +2742,12 @@ export class SyncClient {
|
|
|
2732
2742
|
const operations: OutboxOperation[] = mutations.map((mutation) => {
|
|
2733
2743
|
const table = this.#table(mutation.table);
|
|
2734
2744
|
if (mutation.op === 'delete') {
|
|
2735
|
-
return {
|
|
2745
|
+
return definedObject({
|
|
2736
2746
|
table: mutation.table,
|
|
2737
2747
|
rowId: mutation.rowId,
|
|
2738
2748
|
op: 'delete',
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
: {}),
|
|
2742
|
-
};
|
|
2749
|
+
baseVersion: mutation.baseVersion,
|
|
2750
|
+
});
|
|
2743
2751
|
}
|
|
2744
2752
|
const json: Record<string, JsonRowValue> = {};
|
|
2745
2753
|
if (mutation.op === 'patch') {
|
|
@@ -2750,6 +2758,16 @@ export class SyncClient {
|
|
|
2750
2758
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
2751
2759
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
2752
2760
|
const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
|
|
2761
|
+
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
2762
|
+
const local = this.#db.query(
|
|
2763
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
2764
|
+
[pkValue],
|
|
2765
|
+
)[0];
|
|
2766
|
+
if (!local)
|
|
2767
|
+
throw new ClientSyncError(
|
|
2768
|
+
'sync.row_missing',
|
|
2769
|
+
'a sparse patch requires a local row',
|
|
2770
|
+
);
|
|
2753
2771
|
for (const column of table.columns) {
|
|
2754
2772
|
if (!normalized.has(column.name)) continue;
|
|
2755
2773
|
// §3.4 rule 5 / §6.2: scope columns are immutable on update. The
|
|
@@ -2763,16 +2781,7 @@ export class SyncClient {
|
|
|
2763
2781
|
// by construction the row id being patched (§6.1), so its value is
|
|
2764
2782
|
// proven equal already.
|
|
2765
2783
|
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
2766
|
-
const pkValue = normalized.get(pkColumn.name);
|
|
2767
|
-
const local =
|
|
2768
|
-
typeof pkValue === 'string' && pkValue.length > 0
|
|
2769
|
-
? this.#db.query(
|
|
2770
|
-
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2771
|
-
[pkValue],
|
|
2772
|
-
)[0]
|
|
2773
|
-
: undefined;
|
|
2774
2784
|
if (
|
|
2775
|
-
local === undefined ||
|
|
2776
2785
|
!Object.is(
|
|
2777
2786
|
rowValueToJson(
|
|
2778
2787
|
fromSqlValue(column, local[column.name] ?? null),
|
|
@@ -2811,21 +2820,14 @@ export class SyncClient {
|
|
|
2811
2820
|
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
2812
2821
|
});
|
|
2813
2822
|
}
|
|
2814
|
-
const pkValue = json[table.primaryKey];
|
|
2815
|
-
|
|
2816
|
-
throw invalidRequest(
|
|
2817
|
-
`table ${table.name}: upsert requires a non-empty string primary key`,
|
|
2818
|
-
);
|
|
2819
|
-
}
|
|
2820
|
-
return {
|
|
2823
|
+
const pkValue = requireRowId(json[table.primaryKey]);
|
|
2824
|
+
return definedObject({
|
|
2821
2825
|
table: mutation.table,
|
|
2822
2826
|
rowId: pkValue,
|
|
2823
2827
|
op: 'upsert',
|
|
2824
|
-
|
|
2825
|
-
? { baseVersion: mutation.baseVersion }
|
|
2826
|
-
: {}),
|
|
2828
|
+
baseVersion: mutation.baseVersion,
|
|
2827
2829
|
values: json,
|
|
2828
|
-
};
|
|
2830
|
+
});
|
|
2829
2831
|
});
|
|
2830
2832
|
this.#applyBatch((batch) => {
|
|
2831
2833
|
this.#db.transaction(() => {
|
|
@@ -2851,10 +2853,7 @@ export class SyncClient {
|
|
|
2851
2853
|
continue;
|
|
2852
2854
|
}
|
|
2853
2855
|
this.#db.exec(
|
|
2854
|
-
|
|
2855
|
-
SELECT ?, ? WHERE EXISTS (
|
|
2856
|
-
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
2857
|
-
)`,
|
|
2856
|
+
'INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)',
|
|
2858
2857
|
[clientCommitId, blobId, blobId],
|
|
2859
2858
|
);
|
|
2860
2859
|
}
|
|
@@ -2960,7 +2959,8 @@ export class SyncClient {
|
|
|
2960
2959
|
// mirror row is touched and unconditionally.
|
|
2961
2960
|
this.#dropPreviousVersion();
|
|
2962
2961
|
|
|
2963
|
-
const
|
|
2962
|
+
const priorRejections = [...this.#rejections];
|
|
2963
|
+
const priorConflicts = [...this.#conflicts];
|
|
2964
2964
|
try {
|
|
2965
2965
|
return this.#applyBatch((batch) => {
|
|
2966
2966
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
@@ -2973,6 +2973,14 @@ export class SyncClient {
|
|
|
2973
2973
|
),
|
|
2974
2974
|
);
|
|
2975
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
|
+
);
|
|
2976
2984
|
for (const operation of commit.operations)
|
|
2977
2985
|
batch.table(operation.table);
|
|
2978
2986
|
batch.outcomes();
|
|
@@ -3042,7 +3050,7 @@ export class SyncClient {
|
|
|
3042
3050
|
for (let offset = 0; offset < values.length; offset += 400) {
|
|
3043
3051
|
const chunk = values.slice(offset, offset + 400);
|
|
3044
3052
|
this.#db.exec(
|
|
3045
|
-
`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN
|
|
3053
|
+
`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`,
|
|
3046
3054
|
chunk,
|
|
3047
3055
|
);
|
|
3048
3056
|
}
|
|
@@ -3090,7 +3098,8 @@ export class SyncClient {
|
|
|
3090
3098
|
} catch (error) {
|
|
3091
3099
|
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
3092
3100
|
// in-memory rejection cache before surfacing the storage failure.
|
|
3093
|
-
this.#rejections
|
|
3101
|
+
this.#rejections = priorRejections;
|
|
3102
|
+
this.#conflicts = priorConflicts;
|
|
3094
3103
|
throw error;
|
|
3095
3104
|
}
|
|
3096
3105
|
}
|
|
@@ -3196,7 +3205,7 @@ export class SyncClient {
|
|
|
3196
3205
|
params.push(...selector.values);
|
|
3197
3206
|
}
|
|
3198
3207
|
for (const row of this.#db.query(
|
|
3199
|
-
`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)
|
|
3208
|
+
`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
3200
3209
|
params,
|
|
3201
3210
|
)) {
|
|
3202
3211
|
if (typeof row.id === 'string') ids.add(row.id);
|
|
@@ -3255,7 +3264,7 @@ export class SyncClient {
|
|
|
3255
3264
|
// Pin before the first encryption await: mutations can append while a
|
|
3256
3265
|
// round is encoding, and belong to the next request.
|
|
3257
3266
|
const bounds = this.#db.query(
|
|
3258
|
-
'SELECT COUNT(*)
|
|
3267
|
+
'SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox',
|
|
3259
3268
|
)[0]!;
|
|
3260
3269
|
const pendingCount = bounds.count as number;
|
|
3261
3270
|
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
@@ -3292,7 +3301,7 @@ export class SyncClient {
|
|
|
3292
3301
|
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
3293
3302
|
if (selector === undefined) return undefined;
|
|
3294
3303
|
const row = this.#db.query(
|
|
3295
|
-
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3304
|
+
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
3296
3305
|
[rowId],
|
|
3297
3306
|
)[0];
|
|
3298
3307
|
const value = row?.value;
|
|
@@ -3405,16 +3414,16 @@ export class SyncClient {
|
|
|
3405
3414
|
const error = classifySqliteFailure(caught).error;
|
|
3406
3415
|
const completedAtMs = this.#now();
|
|
3407
3416
|
const code = (error as { code?: unknown }).code;
|
|
3408
|
-
this.#progress.update(
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3417
|
+
this.#progress.update(
|
|
3418
|
+
definedObject({
|
|
3419
|
+
state: 'failed',
|
|
3420
|
+
errorCode:
|
|
3421
|
+
typeof code === 'string'
|
|
3422
|
+
? this.#diagnosticCode(code)
|
|
3423
|
+
: 'client.unknown_failure',
|
|
3424
|
+
retryDelayMs: this.#roundRetryDelayMs,
|
|
3425
|
+
}),
|
|
3426
|
+
);
|
|
3418
3427
|
this.#lastRound = {
|
|
3419
3428
|
status: 'failed',
|
|
3420
3429
|
startedAtMs,
|
|
@@ -3519,12 +3528,12 @@ export class SyncClient {
|
|
|
3519
3528
|
);
|
|
3520
3529
|
const limits = this.#config.limits;
|
|
3521
3530
|
const frames: RequestFrame[] = [
|
|
3522
|
-
{
|
|
3531
|
+
definedObject({
|
|
3523
3532
|
type: 'REQ_HEADER',
|
|
3524
3533
|
clientId: this.#clientId,
|
|
3525
3534
|
schemaVersion: this.#schema.version,
|
|
3526
|
-
|
|
3527
|
-
},
|
|
3535
|
+
logEpoch: logEpoch,
|
|
3536
|
+
}),
|
|
3528
3537
|
...pushFrames,
|
|
3529
3538
|
{
|
|
3530
3539
|
type: 'PULL_HEADER',
|
|
@@ -3534,17 +3543,16 @@ export class SyncClient {
|
|
|
3534
3543
|
accept: this.#acceptMask(),
|
|
3535
3544
|
},
|
|
3536
3545
|
...subs.map(
|
|
3537
|
-
(sub): RequestFrame =>
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
}),
|
|
3546
|
+
(sub): RequestFrame =>
|
|
3547
|
+
definedObject({
|
|
3548
|
+
type: 'SUBSCRIPTION',
|
|
3549
|
+
id: sub.id,
|
|
3550
|
+
table: sub.table,
|
|
3551
|
+
scopes: sub.scopes,
|
|
3552
|
+
params: sub.params,
|
|
3553
|
+
cursor: sub.cursor,
|
|
3554
|
+
bootstrapState: sub.bootstrapState,
|
|
3555
|
+
}),
|
|
3548
3556
|
),
|
|
3549
3557
|
];
|
|
3550
3558
|
const requestBytes = encodeMessage({
|
|
@@ -4078,12 +4086,10 @@ export class SyncClient {
|
|
|
4078
4086
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
4079
4087
|
// when the APP updates (recreating the client with a new generated
|
|
4080
4088
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
4081
|
-
const schemaFloor: SchemaFloor = {
|
|
4089
|
+
const schemaFloor: SchemaFloor = definedObject({
|
|
4082
4090
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
: {}),
|
|
4086
|
-
};
|
|
4091
|
+
latestSchemaVersion: header.latestSchemaVersion,
|
|
4092
|
+
});
|
|
4087
4093
|
this.#setSchemaFloor(schemaFloor);
|
|
4088
4094
|
return {
|
|
4089
4095
|
...summary,
|
|
@@ -4511,7 +4517,7 @@ export class SyncClient {
|
|
|
4511
4517
|
for (const result of frame.results) {
|
|
4512
4518
|
const operation = commit.operations[result.opIndex];
|
|
4513
4519
|
if (result.status === 'conflict') {
|
|
4514
|
-
const conflict: ConflictRecord = {
|
|
4520
|
+
const conflict: ConflictRecord = definedObject({
|
|
4515
4521
|
clientCommitId: frame.clientCommitId,
|
|
4516
4522
|
opIndex: result.opIndex,
|
|
4517
4523
|
table: operation?.table ?? '',
|
|
@@ -4524,23 +4530,23 @@ export class SyncClient {
|
|
|
4524
4530
|
operation?.table,
|
|
4525
4531
|
result.conflictColumns,
|
|
4526
4532
|
),
|
|
4527
|
-
|
|
4528
|
-
};
|
|
4533
|
+
operation: operation,
|
|
4534
|
+
});
|
|
4529
4535
|
this.#conflicts.push(conflict);
|
|
4530
4536
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
4531
4537
|
batch.conflicts();
|
|
4532
4538
|
summary.conflicts.push(conflict);
|
|
4533
4539
|
} else if (result.status === 'error') {
|
|
4534
4540
|
const details = rejectionDetails?.get(result.opIndex);
|
|
4535
|
-
const rejection: RejectionRecord = {
|
|
4541
|
+
const rejection: RejectionRecord = definedObject({
|
|
4536
4542
|
clientCommitId: frame.clientCommitId,
|
|
4537
4543
|
opIndex: result.opIndex,
|
|
4538
4544
|
code: result.code,
|
|
4539
4545
|
message: result.message,
|
|
4540
4546
|
retryable: result.retryable,
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
};
|
|
4547
|
+
details: details,
|
|
4548
|
+
operation: operation,
|
|
4549
|
+
});
|
|
4544
4550
|
this.#rejections.push(rejection);
|
|
4545
4551
|
outcomeResults.push({ status: 'error', rejection });
|
|
4546
4552
|
batch.rejections();
|
|
@@ -4578,7 +4584,7 @@ export class SyncClient {
|
|
|
4578
4584
|
#outboxCommitExists(clientCommitId: string): boolean {
|
|
4579
4585
|
return (
|
|
4580
4586
|
this.#db.query(
|
|
4581
|
-
'SELECT 1 FROM _syncular_outbox WHERE client_commit_id
|
|
4587
|
+
'SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1',
|
|
4582
4588
|
[clientCommitId],
|
|
4583
4589
|
).length > 0
|
|
4584
4590
|
);
|
|
@@ -4631,7 +4637,14 @@ export class SyncClient {
|
|
|
4631
4637
|
(fn) =>
|
|
4632
4638
|
this.#applyBatch((batch) => {
|
|
4633
4639
|
this.#recordCommitChanges(batch, frame);
|
|
4634
|
-
|
|
4640
|
+
const result = fn();
|
|
4641
|
+
if (
|
|
4642
|
+
this.#retainFailedCommits &&
|
|
4643
|
+
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
4644
|
+
.length > 0
|
|
4645
|
+
)
|
|
4646
|
+
this.#replayOutbox();
|
|
4647
|
+
return result;
|
|
4635
4648
|
}),
|
|
4636
4649
|
);
|
|
4637
4650
|
summary.commitsApplied += 1;
|
|
@@ -4664,9 +4677,7 @@ export class SyncClient {
|
|
|
4664
4677
|
);
|
|
4665
4678
|
if (mappings.length === 0) return false;
|
|
4666
4679
|
const row = this.#db.query(
|
|
4667
|
-
`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
4668
|
-
FROM ${quoteIdent(table.name)}
|
|
4669
|
-
WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
4680
|
+
`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
4670
4681
|
[rowId],
|
|
4671
4682
|
)[0];
|
|
4672
4683
|
if (row === undefined) return false;
|
|
@@ -4698,8 +4709,7 @@ export class SyncClient {
|
|
|
4698
4709
|
}
|
|
4699
4710
|
return (
|
|
4700
4711
|
this.#db.query(
|
|
4701
|
-
`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
4702
|
-
WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
4712
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`,
|
|
4703
4713
|
params,
|
|
4704
4714
|
).length > 0
|
|
4705
4715
|
);
|
|
@@ -4737,18 +4747,19 @@ export class SyncClient {
|
|
|
4737
4747
|
) {
|
|
4738
4748
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4739
4749
|
this.#applyBatch((batch) => {
|
|
4740
|
-
saveSubscription(
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
:
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4750
|
+
saveSubscription(
|
|
4751
|
+
this.#db,
|
|
4752
|
+
definedObject({
|
|
4753
|
+
id: sub.id,
|
|
4754
|
+
table: sub.table,
|
|
4755
|
+
scopes: sub.scopes,
|
|
4756
|
+
params: sub.params,
|
|
4757
|
+
cursor: sub.cursor,
|
|
4758
|
+
effectiveScopes: sub.effectiveScopes,
|
|
4759
|
+
status: 'failed',
|
|
4760
|
+
reasonCode: 'sync.scope_revoked',
|
|
4761
|
+
}),
|
|
4762
|
+
);
|
|
4752
4763
|
if (registered !== undefined) {
|
|
4753
4764
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4754
4765
|
}
|
|
@@ -4851,16 +4862,19 @@ export class SyncClient {
|
|
|
4851
4862
|
wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
4852
4863
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4853
4864
|
this.#applyBatch((batch) => {
|
|
4854
|
-
saveSubscription(
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4865
|
+
saveSubscription(
|
|
4866
|
+
this.#db,
|
|
4867
|
+
definedObject({
|
|
4868
|
+
id: sub.id,
|
|
4869
|
+
table: sub.table,
|
|
4870
|
+
scopes: sub.scopes,
|
|
4871
|
+
params: sub.params,
|
|
4872
|
+
cursor: nextCursor,
|
|
4873
|
+
bootstrapState: bootstrapState,
|
|
4874
|
+
effectiveScopes: start.effectiveScopes,
|
|
4875
|
+
status: 'active',
|
|
4876
|
+
}),
|
|
4877
|
+
);
|
|
4864
4878
|
if (completed && registered !== undefined) {
|
|
4865
4879
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
4866
4880
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -4875,18 +4889,19 @@ export class SyncClient {
|
|
|
4875
4889
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
4876
4890
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
4877
4891
|
this.#applyBatch((batch) => {
|
|
4878
|
-
saveSubscription(
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
:
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4892
|
+
saveSubscription(
|
|
4893
|
+
this.#db,
|
|
4894
|
+
definedObject({
|
|
4895
|
+
id: sub.id,
|
|
4896
|
+
table: sub.table,
|
|
4897
|
+
scopes: sub.scopes,
|
|
4898
|
+
params: sub.params,
|
|
4899
|
+
cursor: -1,
|
|
4900
|
+
effectiveScopes: sub.effectiveScopes,
|
|
4901
|
+
status: 'active',
|
|
4902
|
+
reasonCode: start.reasonCode,
|
|
4903
|
+
}),
|
|
4904
|
+
);
|
|
4890
4905
|
if (registered !== undefined) {
|
|
4891
4906
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4892
4907
|
}
|
|
@@ -4919,6 +4934,14 @@ export class SyncClient {
|
|
|
4919
4934
|
),
|
|
4920
4935
|
);
|
|
4921
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
|
+
);
|
|
4922
4945
|
for (const operation of commit.operations)
|
|
4923
4946
|
batch.table(operation.table);
|
|
4924
4947
|
batch.outcomes();
|
|
@@ -4980,18 +5003,19 @@ export class SyncClient {
|
|
|
4980
5003
|
}
|
|
4981
5004
|
}
|
|
4982
5005
|
}
|
|
4983
|
-
saveSubscription(
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
:
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
5006
|
+
saveSubscription(
|
|
5007
|
+
this.#db,
|
|
5008
|
+
definedObject({
|
|
5009
|
+
id: sub.id,
|
|
5010
|
+
table: sub.table,
|
|
5011
|
+
scopes: sub.scopes,
|
|
5012
|
+
params: sub.params,
|
|
5013
|
+
cursor: nextCursor,
|
|
5014
|
+
effectiveScopes: lastEffective,
|
|
5015
|
+
status: failed ? 'failed' : 'revoked',
|
|
5016
|
+
reasonCode: start.reasonCode,
|
|
5017
|
+
}),
|
|
5018
|
+
);
|
|
4995
5019
|
if (registered !== undefined) {
|
|
4996
5020
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
4997
5021
|
}
|
|
@@ -5014,7 +5038,7 @@ export class SyncClient {
|
|
|
5014
5038
|
quoteIdent(SYNC_VERSION_COLUMN),
|
|
5015
5039
|
];
|
|
5016
5040
|
const row = this.#db.query(
|
|
5017
|
-
`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5041
|
+
`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5018
5042
|
[operation.rowId],
|
|
5019
5043
|
)[0];
|
|
5020
5044
|
if (row === undefined) return { opIndex, existed: false };
|
|
@@ -5078,7 +5102,7 @@ export class SyncClient {
|
|
|
5078
5102
|
const table = this.#schema.tables.get(operation.table);
|
|
5079
5103
|
if (table === undefined) continue;
|
|
5080
5104
|
const row = this.#db.query(
|
|
5081
|
-
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5105
|
+
`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5082
5106
|
[operation.rowId],
|
|
5083
5107
|
)[0];
|
|
5084
5108
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
@@ -5134,6 +5158,7 @@ export class SyncClient {
|
|
|
5134
5158
|
#applyOperationsLocally(
|
|
5135
5159
|
operations: readonly OutboxOperation[],
|
|
5136
5160
|
batch?: ChangeAccumulator,
|
|
5161
|
+
replay = false,
|
|
5137
5162
|
): void {
|
|
5138
5163
|
for (const op of operations) {
|
|
5139
5164
|
const table = this.#table(op.table);
|
|
@@ -5153,7 +5178,7 @@ export class SyncClient {
|
|
|
5153
5178
|
const opValues = op.values ?? {};
|
|
5154
5179
|
const full = table.columns.every((column) => column.name in opValues);
|
|
5155
5180
|
const local = this.#db.query(
|
|
5156
|
-
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
5181
|
+
`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
5157
5182
|
[op.rowId],
|
|
5158
5183
|
)[0];
|
|
5159
5184
|
if (local === undefined && !full) {
|
|
@@ -5184,6 +5209,21 @@ export class SyncClient {
|
|
|
5184
5209
|
local === undefined
|
|
5185
5210
|
? OPTIMISTIC_VERSION
|
|
5186
5211
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5212
|
+
if (
|
|
5213
|
+
replay &&
|
|
5214
|
+
table.indexes.some((index) => index.unique) &&
|
|
5215
|
+
uniqueConflicts(
|
|
5216
|
+
this.#db,
|
|
5217
|
+
table,
|
|
5218
|
+
Object.fromEntries(
|
|
5219
|
+
table.columns.map((column, index) => [
|
|
5220
|
+
column.name,
|
|
5221
|
+
values[index] ?? null,
|
|
5222
|
+
]),
|
|
5223
|
+
),
|
|
5224
|
+
).length
|
|
5225
|
+
)
|
|
5226
|
+
continue;
|
|
5187
5227
|
upsertLocalRow(this.#db, table, values, version, false);
|
|
5188
5228
|
}
|
|
5189
5229
|
}
|
|
@@ -5200,7 +5240,7 @@ export class SyncClient {
|
|
|
5200
5240
|
restoreFailedBases(this.#db, this.#schema);
|
|
5201
5241
|
restoreFailedBases(this.#db, this.#schema, true);
|
|
5202
5242
|
for (const commit of pending)
|
|
5203
|
-
this.#applyOperationsLocally(commit.operations, batch);
|
|
5243
|
+
this.#applyOperationsLocally(commit.operations, batch, true);
|
|
5204
5244
|
});
|
|
5205
5245
|
});
|
|
5206
5246
|
}
|
|
@@ -5239,3 +5279,24 @@ export class SyncClient {
|
|
|
5239
5279
|
}
|
|
5240
5280
|
}
|
|
5241
5281
|
}
|
|
5282
|
+
|
|
5283
|
+
type DefinedObject<T> = {
|
|
5284
|
+
[K in keyof T as undefined extends T[K] ? never : K]: T[K];
|
|
5285
|
+
} & {
|
|
5286
|
+
[K in keyof T as undefined extends T[K] ? K : never]?: Exclude<
|
|
5287
|
+
T[K],
|
|
5288
|
+
undefined
|
|
5289
|
+
>;
|
|
5290
|
+
};
|
|
5291
|
+
function definedObject<const T extends object>(record: T): DefinedObject<T> {
|
|
5292
|
+
for (const key of Object.keys(record) as (keyof T)[]) {
|
|
5293
|
+
if (record[key] === undefined) delete record[key];
|
|
5294
|
+
}
|
|
5295
|
+
return record as DefinedObject<T>;
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
function requireRowId(value: unknown): string {
|
|
5299
|
+
if (typeof value !== 'string' || !value.length)
|
|
5300
|
+
throw invalidRequest('a mutation requires a non-empty string primary key');
|
|
5301
|
+
return value;
|
|
5302
|
+
}
|