@syncular/client 0.30.10 → 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 +8 -0
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +118 -117
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +82 -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 +3 -3
- package/src/apply.ts +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +222 -163
- package/src/failed-overlay.ts +155 -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/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, } from './failed-overlay.js';
|
|
1
|
+
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
2
2
|
import { ProgressEmitter, } from './progress.js';
|
|
3
3
|
/**
|
|
4
4
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
@@ -26,7 +26,7 @@ import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolutio
|
|
|
26
26
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
27
27
|
import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
|
|
28
28
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
29
|
-
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
29
|
+
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
30
30
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
31
31
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
32
32
|
export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
|
|
@@ -309,7 +309,7 @@ export class SyncClient {
|
|
|
309
309
|
this.#reconcilePreviousVersionAtBoot();
|
|
310
310
|
// A registration remains app intent only while its table exists in the
|
|
311
311
|
// running schema. Removed-table registrations would poison every pull.
|
|
312
|
-
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db),
|
|
312
|
+
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
|
|
313
313
|
this.#started = true;
|
|
314
314
|
// A persisted active subscription needs one catch-up round on every open:
|
|
315
315
|
// realtime only covers changes after the socket connects, and an
|
|
@@ -355,6 +355,7 @@ export class SyncClient {
|
|
|
355
355
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
356
356
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
357
357
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
358
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
358
359
|
});
|
|
359
360
|
return;
|
|
360
361
|
}
|
|
@@ -366,6 +367,7 @@ export class SyncClient {
|
|
|
366
367
|
this.#db.transaction(() => {
|
|
367
368
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
368
369
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
370
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
369
371
|
});
|
|
370
372
|
return;
|
|
371
373
|
}
|
|
@@ -393,10 +395,12 @@ export class SyncClient {
|
|
|
393
395
|
if (this.#previousVersion !== undefined) {
|
|
394
396
|
writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
|
|
395
397
|
}
|
|
398
|
+
pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
|
|
396
399
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
397
400
|
resetSubscriptionsForBump(this.#db);
|
|
398
401
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
399
402
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
403
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
400
404
|
});
|
|
401
405
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
402
406
|
for (const table of this.#schema.tables.values())
|
|
@@ -709,13 +713,13 @@ export class SyncClient {
|
|
|
709
713
|
previous.sqliteCode !== sqliteCode ||
|
|
710
714
|
previous.tables.join('\0') !== tables.join('\0')) {
|
|
711
715
|
this.#queryFailures.delete(owner.id);
|
|
712
|
-
this.#queryFailures.set(owner.id, {
|
|
716
|
+
this.#queryFailures.set(owner.id, definedObject({
|
|
713
717
|
id: owner.id,
|
|
714
718
|
tables,
|
|
715
719
|
code,
|
|
716
|
-
|
|
720
|
+
sqliteCode: sqliteCode,
|
|
717
721
|
atMs: this.#now(),
|
|
718
|
-
});
|
|
722
|
+
}));
|
|
719
723
|
for (const id of this.#queryFailures.keys()) {
|
|
720
724
|
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
721
725
|
break;
|
|
@@ -1036,22 +1040,18 @@ export class SyncClient {
|
|
|
1036
1040
|
}
|
|
1037
1041
|
return a.id.localeCompare(b.id);
|
|
1038
1042
|
});
|
|
1039
|
-
return {
|
|
1043
|
+
return definedObject({
|
|
1040
1044
|
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1041
1045
|
capturedAtMs,
|
|
1042
|
-
host: {
|
|
1046
|
+
host: definedObject({
|
|
1043
1047
|
kind: 'direct',
|
|
1044
1048
|
role: 'single',
|
|
1045
1049
|
connectivity,
|
|
1046
1050
|
realtime: this.#realtimeState,
|
|
1047
1051
|
realtimePolicy: this.#realtimePolicy,
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
...(this.#realtimeRetryDelayMs !== undefined
|
|
1052
|
-
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1053
|
-
: {}),
|
|
1054
|
-
},
|
|
1052
|
+
realtimeReasonCode: this.#realtimeReasonCode,
|
|
1053
|
+
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1054
|
+
}),
|
|
1055
1055
|
securityLifecycle: this.#securityLifecycle,
|
|
1056
1056
|
schema: {
|
|
1057
1057
|
currentVersion: this.#config.schema.version,
|
|
@@ -1071,24 +1071,20 @@ export class SyncClient {
|
|
|
1071
1071
|
lease: leaseState,
|
|
1072
1072
|
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
1073
1073
|
subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
? { lastChange: this.#lastChange }
|
|
1077
|
-
: {}),
|
|
1074
|
+
lastRound: this.#lastRound,
|
|
1075
|
+
lastChange: this.#lastChange,
|
|
1078
1076
|
storage: this.#diagnosticStorage(),
|
|
1079
1077
|
queryFailures: [...this.#queryFailures.values()],
|
|
1080
|
-
};
|
|
1078
|
+
});
|
|
1081
1079
|
}
|
|
1082
1080
|
#diagnosticLease(nowMs) {
|
|
1083
1081
|
const lease = this.#leaseState;
|
|
1084
1082
|
if (lease?.errorCode !== undefined) {
|
|
1085
|
-
return {
|
|
1083
|
+
return definedObject({
|
|
1086
1084
|
state: 'stopped',
|
|
1087
1085
|
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
: {}),
|
|
1091
|
-
};
|
|
1086
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1087
|
+
});
|
|
1092
1088
|
}
|
|
1093
1089
|
if (lease?.expiresAtMs === undefined)
|
|
1094
1090
|
return { state: 'none' };
|
|
@@ -1101,12 +1097,10 @@ export class SyncClient {
|
|
|
1101
1097
|
try {
|
|
1102
1098
|
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
1103
1099
|
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
1104
|
-
const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),
|
|
1105
|
-
const outcome = this.#db.query(
|
|
1106
|
-
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
1107
|
-
FROM _syncular_commit_outcomes`)[0];
|
|
1100
|
+
const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
|
|
1101
|
+
const outcome = this.#db.query('SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM _syncular_commit_outcomes')[0];
|
|
1108
1102
|
const blobBytes = this.#hasBlobs
|
|
1109
|
-
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),
|
|
1103
|
+
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
1110
1104
|
: 0;
|
|
1111
1105
|
const pressure = this.#config.blobCacheMaxBytes !== undefined &&
|
|
1112
1106
|
blobBytes > this.#config.blobCacheMaxBytes;
|
|
@@ -1460,7 +1454,7 @@ export class SyncClient {
|
|
|
1460
1454
|
const priorRejections = this.#rejections;
|
|
1461
1455
|
return this.#applyBatch((batch) => {
|
|
1462
1456
|
restoreFailedBases(this.#db, this.#schema);
|
|
1463
|
-
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id
|
|
1457
|
+
this.#db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id=?', [
|
|
1464
1458
|
input.clientCommitId,
|
|
1465
1459
|
]);
|
|
1466
1460
|
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
@@ -1579,14 +1573,14 @@ export class SyncClient {
|
|
|
1579
1573
|
}
|
|
1580
1574
|
return;
|
|
1581
1575
|
}
|
|
1582
|
-
saveSubscription(this.#db, {
|
|
1576
|
+
saveSubscription(this.#db, definedObject({
|
|
1583
1577
|
id: input.id,
|
|
1584
1578
|
table: input.table,
|
|
1585
1579
|
scopes: input.scopes,
|
|
1586
|
-
|
|
1580
|
+
params: input.params,
|
|
1587
1581
|
cursor: -1,
|
|
1588
1582
|
status: 'active',
|
|
1589
|
-
});
|
|
1583
|
+
}));
|
|
1590
1584
|
this.#setSyncNeeded(true);
|
|
1591
1585
|
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1592
1586
|
this.#emitDiagnostics();
|
|
@@ -1644,14 +1638,14 @@ export class SyncClient {
|
|
|
1644
1638
|
// Re-entry cancels any deferred eviction for this sub id.
|
|
1645
1639
|
deletePendingEviction(this.#db, subId);
|
|
1646
1640
|
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
1647
|
-
saveSubscription(this.#db, {
|
|
1641
|
+
saveSubscription(this.#db, definedObject({
|
|
1648
1642
|
id: subId,
|
|
1649
1643
|
table: base.table,
|
|
1650
1644
|
scopes: unitScopes(base, unit),
|
|
1651
|
-
|
|
1645
|
+
params: base.params,
|
|
1652
1646
|
cursor: -1,
|
|
1653
1647
|
status: 'active',
|
|
1654
|
-
});
|
|
1648
|
+
}));
|
|
1655
1649
|
});
|
|
1656
1650
|
this.#needsPull = true;
|
|
1657
1651
|
batch.window(baseKey, base.table, unit);
|
|
@@ -1809,14 +1803,12 @@ export class SyncClient {
|
|
|
1809
1803
|
const operations = mutations.map((mutation) => {
|
|
1810
1804
|
const table = this.#table(mutation.table);
|
|
1811
1805
|
if (mutation.op === 'delete') {
|
|
1812
|
-
return {
|
|
1806
|
+
return definedObject({
|
|
1813
1807
|
table: mutation.table,
|
|
1814
1808
|
rowId: mutation.rowId,
|
|
1815
1809
|
op: 'delete',
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
: {}),
|
|
1819
|
-
};
|
|
1810
|
+
baseVersion: mutation.baseVersion,
|
|
1811
|
+
});
|
|
1820
1812
|
}
|
|
1821
1813
|
const json = {};
|
|
1822
1814
|
if (mutation.op === 'patch') {
|
|
@@ -1828,7 +1820,7 @@ export class SyncClient {
|
|
|
1828
1820
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1829
1821
|
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1830
1822
|
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
1831
|
-
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
1823
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
|
|
1832
1824
|
if (!local)
|
|
1833
1825
|
throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
|
|
1834
1826
|
for (const column of table.columns) {
|
|
@@ -1868,15 +1860,13 @@ export class SyncClient {
|
|
|
1868
1860
|
});
|
|
1869
1861
|
}
|
|
1870
1862
|
const pkValue = requireRowId(json[table.primaryKey]);
|
|
1871
|
-
return {
|
|
1863
|
+
return definedObject({
|
|
1872
1864
|
table: mutation.table,
|
|
1873
1865
|
rowId: pkValue,
|
|
1874
1866
|
op: 'upsert',
|
|
1875
|
-
|
|
1876
|
-
? { baseVersion: mutation.baseVersion }
|
|
1877
|
-
: {}),
|
|
1867
|
+
baseVersion: mutation.baseVersion,
|
|
1878
1868
|
values: json,
|
|
1879
|
-
};
|
|
1869
|
+
});
|
|
1880
1870
|
});
|
|
1881
1871
|
this.#applyBatch((batch) => {
|
|
1882
1872
|
this.#db.transaction(() => {
|
|
@@ -1900,10 +1890,7 @@ export class SyncClient {
|
|
|
1900
1890
|
catch {
|
|
1901
1891
|
continue;
|
|
1902
1892
|
}
|
|
1903
|
-
this.#db.exec(
|
|
1904
|
-
SELECT ?, ? WHERE EXISTS (
|
|
1905
|
-
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
1906
|
-
)`, [clientCommitId, blobId, blobId]);
|
|
1893
|
+
this.#db.exec('INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)', [clientCommitId, blobId, blobId]);
|
|
1907
1894
|
}
|
|
1908
1895
|
}
|
|
1909
1896
|
try {
|
|
@@ -1990,13 +1977,18 @@ export class SyncClient {
|
|
|
1990
1977
|
// in the running schema), so purge drops it as a fixed step, before any
|
|
1991
1978
|
// mirror row is touched and unconditionally.
|
|
1992
1979
|
this.#dropPreviousVersion();
|
|
1993
|
-
const
|
|
1980
|
+
const priorRejections = [...this.#rejections];
|
|
1981
|
+
const priorConflicts = [...this.#conflicts];
|
|
1994
1982
|
try {
|
|
1995
1983
|
return this.#applyBatch((batch) => {
|
|
1996
1984
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1997
1985
|
restoreFailedBases(this.#db, this.#schema);
|
|
1998
1986
|
const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
1999
1987
|
for (const commit of droppedRetained) {
|
|
1988
|
+
batch.conflicts();
|
|
1989
|
+
batch.rejections();
|
|
1990
|
+
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
1991
|
+
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
2000
1992
|
for (const operation of commit.operations)
|
|
2001
1993
|
batch.table(operation.table);
|
|
2002
1994
|
batch.outcomes();
|
|
@@ -2053,7 +2045,7 @@ export class SyncClient {
|
|
|
2053
2045
|
batch.table(tableName);
|
|
2054
2046
|
for (let offset = 0; offset < values.length; offset += 400) {
|
|
2055
2047
|
const chunk = values.slice(offset, offset + 400);
|
|
2056
|
-
this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN
|
|
2048
|
+
this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`, chunk);
|
|
2057
2049
|
}
|
|
2058
2050
|
}
|
|
2059
2051
|
if (doomed.length > 0) {
|
|
@@ -2096,7 +2088,8 @@ export class SyncClient {
|
|
|
2096
2088
|
catch (error) {
|
|
2097
2089
|
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
2098
2090
|
// in-memory rejection cache before surfacing the storage failure.
|
|
2099
|
-
this.#rejections
|
|
2091
|
+
this.#rejections = priorRejections;
|
|
2092
|
+
this.#conflicts = priorConflicts;
|
|
2100
2093
|
throw error;
|
|
2101
2094
|
}
|
|
2102
2095
|
}
|
|
@@ -2186,7 +2179,7 @@ export class SyncClient {
|
|
|
2186
2179
|
clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
|
|
2187
2180
|
params.push(...selector.values);
|
|
2188
2181
|
}
|
|
2189
|
-
for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)
|
|
2182
|
+
for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)AS id FROM ${quoteIdent(target.table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
|
|
2190
2183
|
if (typeof row.id === 'string')
|
|
2191
2184
|
ids.add(row.id);
|
|
2192
2185
|
}
|
|
@@ -2228,7 +2221,7 @@ export class SyncClient {
|
|
|
2228
2221
|
async #encodeOutboxForPush() {
|
|
2229
2222
|
// Pin before the first encryption await: mutations can append while a
|
|
2230
2223
|
// round is encoding, and belong to the next request.
|
|
2231
|
-
const bounds = this.#db.query('SELECT COUNT(*)
|
|
2224
|
+
const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
|
|
2232
2225
|
const pendingCount = bounds.count;
|
|
2233
2226
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2234
2227
|
const pushFrames = [];
|
|
@@ -2258,7 +2251,7 @@ export class SyncClient {
|
|
|
2258
2251
|
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2259
2252
|
if (selector === undefined)
|
|
2260
2253
|
return undefined;
|
|
2261
|
-
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
2254
|
+
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
|
|
2262
2255
|
const value = row?.value;
|
|
2263
2256
|
return typeof value === 'string' && value.length > 0
|
|
2264
2257
|
? value
|
|
@@ -2351,15 +2344,13 @@ export class SyncClient {
|
|
|
2351
2344
|
const error = classifySqliteFailure(caught).error;
|
|
2352
2345
|
const completedAtMs = this.#now();
|
|
2353
2346
|
const code = error.code;
|
|
2354
|
-
this.#progress.update({
|
|
2347
|
+
this.#progress.update(definedObject({
|
|
2355
2348
|
state: 'failed',
|
|
2356
2349
|
errorCode: typeof code === 'string'
|
|
2357
2350
|
? this.#diagnosticCode(code)
|
|
2358
2351
|
: 'client.unknown_failure',
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
: {}),
|
|
2362
|
-
});
|
|
2352
|
+
retryDelayMs: this.#roundRetryDelayMs,
|
|
2353
|
+
}));
|
|
2363
2354
|
this.#lastRound = {
|
|
2364
2355
|
status: 'failed',
|
|
2365
2356
|
startedAtMs,
|
|
@@ -2451,12 +2442,12 @@ export class SyncClient {
|
|
|
2451
2442
|
const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
|
|
2452
2443
|
const limits = this.#config.limits;
|
|
2453
2444
|
const frames = [
|
|
2454
|
-
{
|
|
2445
|
+
definedObject({
|
|
2455
2446
|
type: 'REQ_HEADER',
|
|
2456
2447
|
clientId: this.#clientId,
|
|
2457
2448
|
schemaVersion: this.#schema.version,
|
|
2458
|
-
|
|
2459
|
-
},
|
|
2449
|
+
logEpoch: logEpoch,
|
|
2450
|
+
}),
|
|
2460
2451
|
...pushFrames,
|
|
2461
2452
|
{
|
|
2462
2453
|
type: 'PULL_HEADER',
|
|
@@ -2465,16 +2456,14 @@ export class SyncClient {
|
|
|
2465
2456
|
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
2466
2457
|
accept: this.#acceptMask(),
|
|
2467
2458
|
},
|
|
2468
|
-
...subs.map((sub) => ({
|
|
2459
|
+
...subs.map((sub) => definedObject({
|
|
2469
2460
|
type: 'SUBSCRIPTION',
|
|
2470
2461
|
id: sub.id,
|
|
2471
2462
|
table: sub.table,
|
|
2472
2463
|
scopes: sub.scopes,
|
|
2473
|
-
|
|
2464
|
+
params: sub.params,
|
|
2474
2465
|
cursor: sub.cursor,
|
|
2475
|
-
|
|
2476
|
-
? { bootstrapState: sub.bootstrapState }
|
|
2477
|
-
: {}),
|
|
2466
|
+
bootstrapState: sub.bootstrapState,
|
|
2478
2467
|
})),
|
|
2479
2468
|
];
|
|
2480
2469
|
const requestBytes = encodeMessage({
|
|
@@ -2925,12 +2914,10 @@ export class SyncClient {
|
|
|
2925
2914
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
2926
2915
|
// when the APP updates (recreating the client with a new generated
|
|
2927
2916
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
2928
|
-
const schemaFloor = {
|
|
2917
|
+
const schemaFloor = definedObject({
|
|
2929
2918
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
: {}),
|
|
2933
|
-
};
|
|
2919
|
+
latestSchemaVersion: header.latestSchemaVersion,
|
|
2920
|
+
});
|
|
2934
2921
|
this.#setSchemaFloor(schemaFloor);
|
|
2935
2922
|
return {
|
|
2936
2923
|
...summary,
|
|
@@ -3243,7 +3230,7 @@ export class SyncClient {
|
|
|
3243
3230
|
for (const result of frame.results) {
|
|
3244
3231
|
const operation = commit.operations[result.opIndex];
|
|
3245
3232
|
if (result.status === 'conflict') {
|
|
3246
|
-
const conflict = {
|
|
3233
|
+
const conflict = definedObject({
|
|
3247
3234
|
clientCommitId: frame.clientCommitId,
|
|
3248
3235
|
opIndex: result.opIndex,
|
|
3249
3236
|
table: operation?.table ?? '',
|
|
@@ -3253,8 +3240,8 @@ export class SyncClient {
|
|
|
3253
3240
|
serverVersion: result.serverVersion,
|
|
3254
3241
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3255
3242
|
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
3256
|
-
|
|
3257
|
-
};
|
|
3243
|
+
operation: operation,
|
|
3244
|
+
});
|
|
3258
3245
|
this.#conflicts.push(conflict);
|
|
3259
3246
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
3260
3247
|
batch.conflicts();
|
|
@@ -3262,15 +3249,15 @@ export class SyncClient {
|
|
|
3262
3249
|
}
|
|
3263
3250
|
else if (result.status === 'error') {
|
|
3264
3251
|
const details = rejectionDetails?.get(result.opIndex);
|
|
3265
|
-
const rejection = {
|
|
3252
|
+
const rejection = definedObject({
|
|
3266
3253
|
clientCommitId: frame.clientCommitId,
|
|
3267
3254
|
opIndex: result.opIndex,
|
|
3268
3255
|
code: result.code,
|
|
3269
3256
|
message: result.message,
|
|
3270
3257
|
retryable: result.retryable,
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
};
|
|
3258
|
+
details: details,
|
|
3259
|
+
operation: operation,
|
|
3260
|
+
});
|
|
3274
3261
|
this.#rejections.push(rejection);
|
|
3275
3262
|
outcomeResults.push({ status: 'error', rejection });
|
|
3276
3263
|
batch.rejections();
|
|
@@ -3302,7 +3289,7 @@ export class SyncClient {
|
|
|
3302
3289
|
return true;
|
|
3303
3290
|
}
|
|
3304
3291
|
#outboxCommitExists(clientCommitId) {
|
|
3305
|
-
return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id
|
|
3292
|
+
return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
|
|
3306
3293
|
}
|
|
3307
3294
|
#decodeServerRow(tableName, payload) {
|
|
3308
3295
|
if (tableName === undefined)
|
|
@@ -3339,7 +3326,12 @@ export class SyncClient {
|
|
|
3339
3326
|
async #applyCommit(frame, summary) {
|
|
3340
3327
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3341
3328
|
this.#recordCommitChanges(batch, frame);
|
|
3342
|
-
|
|
3329
|
+
const result = fn();
|
|
3330
|
+
if (this.#retainFailedCommits &&
|
|
3331
|
+
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
3332
|
+
.length > 0)
|
|
3333
|
+
this.#replayOutbox();
|
|
3334
|
+
return result;
|
|
3343
3335
|
}));
|
|
3344
3336
|
summary.commitsApplied += 1;
|
|
3345
3337
|
}
|
|
@@ -3367,9 +3359,7 @@ export class SyncClient {
|
|
|
3367
3359
|
const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
|
|
3368
3360
|
if (mappings.length === 0)
|
|
3369
3361
|
return false;
|
|
3370
|
-
const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
3371
|
-
FROM ${quoteIdent(table.name)}
|
|
3372
|
-
WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
|
|
3362
|
+
const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
|
|
3373
3363
|
if (row === undefined)
|
|
3374
3364
|
return false;
|
|
3375
3365
|
let recorded = false;
|
|
@@ -3397,8 +3387,7 @@ export class SyncClient {
|
|
|
3397
3387
|
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
3398
3388
|
params.push(...values);
|
|
3399
3389
|
}
|
|
3400
|
-
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
3401
|
-
WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
|
|
3390
|
+
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
|
|
3402
3391
|
}
|
|
3403
3392
|
/**
|
|
3404
3393
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -3419,18 +3408,16 @@ export class SyncClient {
|
|
|
3419
3408
|
error.code === 'sync.scope_revoked') {
|
|
3420
3409
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3421
3410
|
this.#applyBatch((batch) => {
|
|
3422
|
-
saveSubscription(this.#db, {
|
|
3411
|
+
saveSubscription(this.#db, definedObject({
|
|
3423
3412
|
id: sub.id,
|
|
3424
3413
|
table: sub.table,
|
|
3425
3414
|
scopes: sub.scopes,
|
|
3426
|
-
|
|
3415
|
+
params: sub.params,
|
|
3427
3416
|
cursor: sub.cursor,
|
|
3428
|
-
|
|
3429
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3430
|
-
: {}),
|
|
3417
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3431
3418
|
status: 'failed',
|
|
3432
3419
|
reasonCode: 'sync.scope_revoked',
|
|
3433
|
-
});
|
|
3420
|
+
}));
|
|
3434
3421
|
if (registered !== undefined) {
|
|
3435
3422
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3436
3423
|
}
|
|
@@ -3507,16 +3494,16 @@ export class SyncClient {
|
|
|
3507
3494
|
const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
3508
3495
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3509
3496
|
this.#applyBatch((batch) => {
|
|
3510
|
-
saveSubscription(this.#db, {
|
|
3497
|
+
saveSubscription(this.#db, definedObject({
|
|
3511
3498
|
id: sub.id,
|
|
3512
3499
|
table: sub.table,
|
|
3513
3500
|
scopes: sub.scopes,
|
|
3514
|
-
|
|
3501
|
+
params: sub.params,
|
|
3515
3502
|
cursor: nextCursor,
|
|
3516
|
-
|
|
3503
|
+
bootstrapState: bootstrapState,
|
|
3517
3504
|
effectiveScopes: start.effectiveScopes,
|
|
3518
3505
|
status: 'active',
|
|
3519
|
-
});
|
|
3506
|
+
}));
|
|
3520
3507
|
if (completed && registered !== undefined) {
|
|
3521
3508
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
3522
3509
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -3530,18 +3517,16 @@ export class SyncClient {
|
|
|
3530
3517
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
3531
3518
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3532
3519
|
this.#applyBatch((batch) => {
|
|
3533
|
-
saveSubscription(this.#db, {
|
|
3520
|
+
saveSubscription(this.#db, definedObject({
|
|
3534
3521
|
id: sub.id,
|
|
3535
3522
|
table: sub.table,
|
|
3536
3523
|
scopes: sub.scopes,
|
|
3537
|
-
|
|
3524
|
+
params: sub.params,
|
|
3538
3525
|
cursor: -1,
|
|
3539
|
-
|
|
3540
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3541
|
-
: {}),
|
|
3526
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3542
3527
|
status: 'active',
|
|
3543
3528
|
reasonCode: start.reasonCode,
|
|
3544
|
-
});
|
|
3529
|
+
}));
|
|
3545
3530
|
if (registered !== undefined) {
|
|
3546
3531
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3547
3532
|
}
|
|
@@ -3563,6 +3548,10 @@ export class SyncClient {
|
|
|
3563
3548
|
const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
|
|
3564
3549
|
Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
|
|
3565
3550
|
for (const commit of droppedRetained) {
|
|
3551
|
+
batch.conflicts();
|
|
3552
|
+
batch.rejections();
|
|
3553
|
+
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3554
|
+
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3566
3555
|
for (const operation of commit.operations)
|
|
3567
3556
|
batch.table(operation.table);
|
|
3568
3557
|
batch.outcomes();
|
|
@@ -3616,18 +3605,16 @@ export class SyncClient {
|
|
|
3616
3605
|
}
|
|
3617
3606
|
}
|
|
3618
3607
|
}
|
|
3619
|
-
saveSubscription(this.#db, {
|
|
3608
|
+
saveSubscription(this.#db, definedObject({
|
|
3620
3609
|
id: sub.id,
|
|
3621
3610
|
table: sub.table,
|
|
3622
3611
|
scopes: sub.scopes,
|
|
3623
|
-
|
|
3612
|
+
params: sub.params,
|
|
3624
3613
|
cursor: nextCursor,
|
|
3625
|
-
|
|
3626
|
-
? { effectiveScopes: lastEffective }
|
|
3627
|
-
: {}),
|
|
3614
|
+
effectiveScopes: lastEffective,
|
|
3628
3615
|
status: failed ? 'failed' : 'revoked',
|
|
3629
3616
|
reasonCode: start.reasonCode,
|
|
3630
|
-
});
|
|
3617
|
+
}));
|
|
3631
3618
|
if (registered !== undefined) {
|
|
3632
3619
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3633
3620
|
}
|
|
@@ -3646,7 +3633,7 @@ export class SyncClient {
|
|
|
3646
3633
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
3647
3634
|
quoteIdent(SYNC_VERSION_COLUMN),
|
|
3648
3635
|
];
|
|
3649
|
-
const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3636
|
+
const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
|
|
3650
3637
|
if (row === undefined)
|
|
3651
3638
|
return { opIndex, existed: false };
|
|
3652
3639
|
const values = {};
|
|
@@ -3686,7 +3673,7 @@ export class SyncClient {
|
|
|
3686
3673
|
const table = this.#schema.tables.get(operation.table);
|
|
3687
3674
|
if (table === undefined)
|
|
3688
3675
|
continue;
|
|
3689
|
-
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3676
|
+
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
|
|
3690
3677
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
3691
3678
|
batch.table(table.name);
|
|
3692
3679
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
@@ -3728,7 +3715,7 @@ export class SyncClient {
|
|
|
3728
3715
|
this.#applyOperationsLocally(affectedOperations, batch);
|
|
3729
3716
|
}
|
|
3730
3717
|
}
|
|
3731
|
-
#applyOperationsLocally(operations, batch) {
|
|
3718
|
+
#applyOperationsLocally(operations, batch, replay = false) {
|
|
3732
3719
|
for (const op of operations) {
|
|
3733
3720
|
const table = this.#table(op.table);
|
|
3734
3721
|
let precise = batch === undefined
|
|
@@ -3746,7 +3733,7 @@ export class SyncClient {
|
|
|
3746
3733
|
// sync.row_missing, and the local mirror never invents a base.
|
|
3747
3734
|
const opValues = op.values ?? {};
|
|
3748
3735
|
const full = table.columns.every((column) => column.name in opValues);
|
|
3749
|
-
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3736
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [op.rowId])[0];
|
|
3750
3737
|
if (local === undefined && !full) {
|
|
3751
3738
|
if (batch !== undefined && !precise)
|
|
3752
3739
|
batch.table(op.table);
|
|
@@ -3777,6 +3764,13 @@ export class SyncClient {
|
|
|
3777
3764
|
const version = local === undefined
|
|
3778
3765
|
? OPTIMISTIC_VERSION
|
|
3779
3766
|
: local[SYNC_VERSION_COLUMN];
|
|
3767
|
+
if (replay &&
|
|
3768
|
+
table.indexes.some((index) => index.unique) &&
|
|
3769
|
+
uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
|
|
3770
|
+
column.name,
|
|
3771
|
+
values[index] ?? null,
|
|
3772
|
+
]))).length)
|
|
3773
|
+
continue;
|
|
3780
3774
|
upsertLocalRow(this.#db, table, values, version, false);
|
|
3781
3775
|
}
|
|
3782
3776
|
}
|
|
@@ -3793,7 +3787,7 @@ export class SyncClient {
|
|
|
3793
3787
|
restoreFailedBases(this.#db, this.#schema);
|
|
3794
3788
|
restoreFailedBases(this.#db, this.#schema, true);
|
|
3795
3789
|
for (const commit of pending)
|
|
3796
|
-
this.#applyOperationsLocally(commit.operations, batch);
|
|
3790
|
+
this.#applyOperationsLocally(commit.operations, batch, true);
|
|
3797
3791
|
});
|
|
3798
3792
|
});
|
|
3799
3793
|
}
|
|
@@ -3822,6 +3816,13 @@ export class SyncClient {
|
|
|
3822
3816
|
}
|
|
3823
3817
|
}
|
|
3824
3818
|
}
|
|
3819
|
+
function definedObject(record) {
|
|
3820
|
+
for (const key of Object.keys(record)) {
|
|
3821
|
+
if (record[key] === undefined)
|
|
3822
|
+
delete record[key];
|
|
3823
|
+
}
|
|
3824
|
+
return record;
|
|
3825
|
+
}
|
|
3825
3826
|
function requireRowId(value) {
|
|
3826
3827
|
if (typeof value !== 'string' || !value.length)
|
|
3827
3828
|
throw invalidRequest('a mutation requires a non-empty string primary key');
|
package/dist/failed-overlay.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ClientDatabase } from './database.js';
|
|
2
|
-
import type { CompiledClientSchema, JsonRowValue } from './schema.js';
|
|
2
|
+
import type { CompiledClientSchema, CompiledClientTable, JsonRowValue } from './schema.js';
|
|
3
|
+
import type { RowValue } from '@syncular/core';
|
|
4
|
+
import type { RetainedCommitRow } from './outcomes.js';
|
|
3
5
|
import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
|
|
4
6
|
/** Protected failure state. The server base advances independently of intent. */
|
|
5
7
|
export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
|
|
6
|
-
export declare function retainedBaseWrite(db: ClientDatabase, table:
|
|
8
|
+
export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledClientTable, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
|
|
7
9
|
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
|
|
10
|
+
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
11
|
+
export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
|
|
8
12
|
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean): void;
|
|
9
13
|
export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
|
|
10
14
|
export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|