@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/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') {
|
|
@@ -1827,6 +1819,10 @@ export class SyncClient {
|
|
|
1827
1819
|
const normalized = normalizeRecordKeys(table, mutation.values);
|
|
1828
1820
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1829
1821
|
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1822
|
+
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
1823
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
|
|
1824
|
+
if (!local)
|
|
1825
|
+
throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
|
|
1830
1826
|
for (const column of table.columns) {
|
|
1831
1827
|
if (!normalized.has(column.name))
|
|
1832
1828
|
continue;
|
|
@@ -1841,12 +1837,7 @@ export class SyncClient {
|
|
|
1841
1837
|
// by construction the row id being patched (§6.1), so its value is
|
|
1842
1838
|
// proven equal already.
|
|
1843
1839
|
if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
|
|
1844
|
-
|
|
1845
|
-
const local = typeof pkValue === 'string' && pkValue.length > 0
|
|
1846
|
-
? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
|
|
1847
|
-
: undefined;
|
|
1848
|
-
if (local === undefined ||
|
|
1849
|
-
!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1840
|
+
if (!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
|
|
1850
1841
|
throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
|
|
1851
1842
|
}
|
|
1852
1843
|
continue;
|
|
@@ -1868,19 +1859,14 @@ export class SyncClient {
|
|
|
1868
1859
|
json[column.name] = rowValueToJson(values[index] ?? null);
|
|
1869
1860
|
});
|
|
1870
1861
|
}
|
|
1871
|
-
const pkValue = json[table.primaryKey];
|
|
1872
|
-
|
|
1873
|
-
throw invalidRequest(`table ${table.name}: upsert requires a non-empty string primary key`);
|
|
1874
|
-
}
|
|
1875
|
-
return {
|
|
1862
|
+
const pkValue = requireRowId(json[table.primaryKey]);
|
|
1863
|
+
return definedObject({
|
|
1876
1864
|
table: mutation.table,
|
|
1877
1865
|
rowId: pkValue,
|
|
1878
1866
|
op: 'upsert',
|
|
1879
|
-
|
|
1880
|
-
? { baseVersion: mutation.baseVersion }
|
|
1881
|
-
: {}),
|
|
1867
|
+
baseVersion: mutation.baseVersion,
|
|
1882
1868
|
values: json,
|
|
1883
|
-
};
|
|
1869
|
+
});
|
|
1884
1870
|
});
|
|
1885
1871
|
this.#applyBatch((batch) => {
|
|
1886
1872
|
this.#db.transaction(() => {
|
|
@@ -1904,10 +1890,7 @@ export class SyncClient {
|
|
|
1904
1890
|
catch {
|
|
1905
1891
|
continue;
|
|
1906
1892
|
}
|
|
1907
|
-
this.#db.exec(
|
|
1908
|
-
SELECT ?, ? WHERE EXISTS (
|
|
1909
|
-
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
1910
|
-
)`, [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]);
|
|
1911
1894
|
}
|
|
1912
1895
|
}
|
|
1913
1896
|
try {
|
|
@@ -1994,13 +1977,18 @@ export class SyncClient {
|
|
|
1994
1977
|
// in the running schema), so purge drops it as a fixed step, before any
|
|
1995
1978
|
// mirror row is touched and unconditionally.
|
|
1996
1979
|
this.#dropPreviousVersion();
|
|
1997
|
-
const
|
|
1980
|
+
const priorRejections = [...this.#rejections];
|
|
1981
|
+
const priorConflicts = [...this.#conflicts];
|
|
1998
1982
|
try {
|
|
1999
1983
|
return this.#applyBatch((batch) => {
|
|
2000
1984
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
2001
1985
|
restoreFailedBases(this.#db, this.#schema);
|
|
2002
1986
|
const droppedRetained = dropFailedRows(this.#db, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
2003
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);
|
|
2004
1992
|
for (const operation of commit.operations)
|
|
2005
1993
|
batch.table(operation.table);
|
|
2006
1994
|
batch.outcomes();
|
|
@@ -2057,7 +2045,7 @@ export class SyncClient {
|
|
|
2057
2045
|
batch.table(tableName);
|
|
2058
2046
|
for (let offset = 0; offset < values.length; offset += 400) {
|
|
2059
2047
|
const chunk = values.slice(offset, offset + 400);
|
|
2060
|
-
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);
|
|
2061
2049
|
}
|
|
2062
2050
|
}
|
|
2063
2051
|
if (doomed.length > 0) {
|
|
@@ -2100,7 +2088,8 @@ export class SyncClient {
|
|
|
2100
2088
|
catch (error) {
|
|
2101
2089
|
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
2102
2090
|
// in-memory rejection cache before surfacing the storage failure.
|
|
2103
|
-
this.#rejections
|
|
2091
|
+
this.#rejections = priorRejections;
|
|
2092
|
+
this.#conflicts = priorConflicts;
|
|
2104
2093
|
throw error;
|
|
2105
2094
|
}
|
|
2106
2095
|
}
|
|
@@ -2190,7 +2179,7 @@ export class SyncClient {
|
|
|
2190
2179
|
clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
|
|
2191
2180
|
params.push(...selector.values);
|
|
2192
2181
|
}
|
|
2193
|
-
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)) {
|
|
2194
2183
|
if (typeof row.id === 'string')
|
|
2195
2184
|
ids.add(row.id);
|
|
2196
2185
|
}
|
|
@@ -2232,7 +2221,7 @@ export class SyncClient {
|
|
|
2232
2221
|
async #encodeOutboxForPush() {
|
|
2233
2222
|
// Pin before the first encryption await: mutations can append while a
|
|
2234
2223
|
// round is encoding, and belong to the next request.
|
|
2235
|
-
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];
|
|
2236
2225
|
const pendingCount = bounds.count;
|
|
2237
2226
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2238
2227
|
const pushFrames = [];
|
|
@@ -2262,7 +2251,7 @@ export class SyncClient {
|
|
|
2262
2251
|
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2263
2252
|
if (selector === undefined)
|
|
2264
2253
|
return undefined;
|
|
2265
|
-
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];
|
|
2266
2255
|
const value = row?.value;
|
|
2267
2256
|
return typeof value === 'string' && value.length > 0
|
|
2268
2257
|
? value
|
|
@@ -2355,15 +2344,13 @@ export class SyncClient {
|
|
|
2355
2344
|
const error = classifySqliteFailure(caught).error;
|
|
2356
2345
|
const completedAtMs = this.#now();
|
|
2357
2346
|
const code = error.code;
|
|
2358
|
-
this.#progress.update({
|
|
2347
|
+
this.#progress.update(definedObject({
|
|
2359
2348
|
state: 'failed',
|
|
2360
2349
|
errorCode: typeof code === 'string'
|
|
2361
2350
|
? this.#diagnosticCode(code)
|
|
2362
2351
|
: 'client.unknown_failure',
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
: {}),
|
|
2366
|
-
});
|
|
2352
|
+
retryDelayMs: this.#roundRetryDelayMs,
|
|
2353
|
+
}));
|
|
2367
2354
|
this.#lastRound = {
|
|
2368
2355
|
status: 'failed',
|
|
2369
2356
|
startedAtMs,
|
|
@@ -2455,12 +2442,12 @@ export class SyncClient {
|
|
|
2455
2442
|
const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
|
|
2456
2443
|
const limits = this.#config.limits;
|
|
2457
2444
|
const frames = [
|
|
2458
|
-
{
|
|
2445
|
+
definedObject({
|
|
2459
2446
|
type: 'REQ_HEADER',
|
|
2460
2447
|
clientId: this.#clientId,
|
|
2461
2448
|
schemaVersion: this.#schema.version,
|
|
2462
|
-
|
|
2463
|
-
},
|
|
2449
|
+
logEpoch: logEpoch,
|
|
2450
|
+
}),
|
|
2464
2451
|
...pushFrames,
|
|
2465
2452
|
{
|
|
2466
2453
|
type: 'PULL_HEADER',
|
|
@@ -2469,16 +2456,14 @@ export class SyncClient {
|
|
|
2469
2456
|
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
2470
2457
|
accept: this.#acceptMask(),
|
|
2471
2458
|
},
|
|
2472
|
-
...subs.map((sub) => ({
|
|
2459
|
+
...subs.map((sub) => definedObject({
|
|
2473
2460
|
type: 'SUBSCRIPTION',
|
|
2474
2461
|
id: sub.id,
|
|
2475
2462
|
table: sub.table,
|
|
2476
2463
|
scopes: sub.scopes,
|
|
2477
|
-
|
|
2464
|
+
params: sub.params,
|
|
2478
2465
|
cursor: sub.cursor,
|
|
2479
|
-
|
|
2480
|
-
? { bootstrapState: sub.bootstrapState }
|
|
2481
|
-
: {}),
|
|
2466
|
+
bootstrapState: sub.bootstrapState,
|
|
2482
2467
|
})),
|
|
2483
2468
|
];
|
|
2484
2469
|
const requestBytes = encodeMessage({
|
|
@@ -2929,12 +2914,10 @@ export class SyncClient {
|
|
|
2929
2914
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
2930
2915
|
// when the APP updates (recreating the client with a new generated
|
|
2931
2916
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
2932
|
-
const schemaFloor = {
|
|
2917
|
+
const schemaFloor = definedObject({
|
|
2933
2918
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
: {}),
|
|
2937
|
-
};
|
|
2919
|
+
latestSchemaVersion: header.latestSchemaVersion,
|
|
2920
|
+
});
|
|
2938
2921
|
this.#setSchemaFloor(schemaFloor);
|
|
2939
2922
|
return {
|
|
2940
2923
|
...summary,
|
|
@@ -3247,7 +3230,7 @@ export class SyncClient {
|
|
|
3247
3230
|
for (const result of frame.results) {
|
|
3248
3231
|
const operation = commit.operations[result.opIndex];
|
|
3249
3232
|
if (result.status === 'conflict') {
|
|
3250
|
-
const conflict = {
|
|
3233
|
+
const conflict = definedObject({
|
|
3251
3234
|
clientCommitId: frame.clientCommitId,
|
|
3252
3235
|
opIndex: result.opIndex,
|
|
3253
3236
|
table: operation?.table ?? '',
|
|
@@ -3257,8 +3240,8 @@ export class SyncClient {
|
|
|
3257
3240
|
serverVersion: result.serverVersion,
|
|
3258
3241
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3259
3242
|
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
3260
|
-
|
|
3261
|
-
};
|
|
3243
|
+
operation: operation,
|
|
3244
|
+
});
|
|
3262
3245
|
this.#conflicts.push(conflict);
|
|
3263
3246
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
3264
3247
|
batch.conflicts();
|
|
@@ -3266,15 +3249,15 @@ export class SyncClient {
|
|
|
3266
3249
|
}
|
|
3267
3250
|
else if (result.status === 'error') {
|
|
3268
3251
|
const details = rejectionDetails?.get(result.opIndex);
|
|
3269
|
-
const rejection = {
|
|
3252
|
+
const rejection = definedObject({
|
|
3270
3253
|
clientCommitId: frame.clientCommitId,
|
|
3271
3254
|
opIndex: result.opIndex,
|
|
3272
3255
|
code: result.code,
|
|
3273
3256
|
message: result.message,
|
|
3274
3257
|
retryable: result.retryable,
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
};
|
|
3258
|
+
details: details,
|
|
3259
|
+
operation: operation,
|
|
3260
|
+
});
|
|
3278
3261
|
this.#rejections.push(rejection);
|
|
3279
3262
|
outcomeResults.push({ status: 'error', rejection });
|
|
3280
3263
|
batch.rejections();
|
|
@@ -3306,7 +3289,7 @@ export class SyncClient {
|
|
|
3306
3289
|
return true;
|
|
3307
3290
|
}
|
|
3308
3291
|
#outboxCommitExists(clientCommitId) {
|
|
3309
|
-
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);
|
|
3310
3293
|
}
|
|
3311
3294
|
#decodeServerRow(tableName, payload) {
|
|
3312
3295
|
if (tableName === undefined)
|
|
@@ -3343,7 +3326,12 @@ export class SyncClient {
|
|
|
3343
3326
|
async #applyCommit(frame, summary) {
|
|
3344
3327
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3345
3328
|
this.#recordCommitChanges(batch, frame);
|
|
3346
|
-
|
|
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;
|
|
3347
3335
|
}));
|
|
3348
3336
|
summary.commitsApplied += 1;
|
|
3349
3337
|
}
|
|
@@ -3371,9 +3359,7 @@ export class SyncClient {
|
|
|
3371
3359
|
const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
|
|
3372
3360
|
if (mappings.length === 0)
|
|
3373
3361
|
return false;
|
|
3374
|
-
const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
|
|
3375
|
-
FROM ${quoteIdent(table.name)}
|
|
3376
|
-
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];
|
|
3377
3363
|
if (row === undefined)
|
|
3378
3364
|
return false;
|
|
3379
3365
|
let recorded = false;
|
|
@@ -3401,8 +3387,7 @@ export class SyncClient {
|
|
|
3401
3387
|
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
3402
3388
|
params.push(...values);
|
|
3403
3389
|
}
|
|
3404
|
-
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
3405
|
-
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);
|
|
3406
3391
|
}
|
|
3407
3392
|
/**
|
|
3408
3393
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -3423,18 +3408,16 @@ export class SyncClient {
|
|
|
3423
3408
|
error.code === 'sync.scope_revoked') {
|
|
3424
3409
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3425
3410
|
this.#applyBatch((batch) => {
|
|
3426
|
-
saveSubscription(this.#db, {
|
|
3411
|
+
saveSubscription(this.#db, definedObject({
|
|
3427
3412
|
id: sub.id,
|
|
3428
3413
|
table: sub.table,
|
|
3429
3414
|
scopes: sub.scopes,
|
|
3430
|
-
|
|
3415
|
+
params: sub.params,
|
|
3431
3416
|
cursor: sub.cursor,
|
|
3432
|
-
|
|
3433
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3434
|
-
: {}),
|
|
3417
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3435
3418
|
status: 'failed',
|
|
3436
3419
|
reasonCode: 'sync.scope_revoked',
|
|
3437
|
-
});
|
|
3420
|
+
}));
|
|
3438
3421
|
if (registered !== undefined) {
|
|
3439
3422
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3440
3423
|
}
|
|
@@ -3511,16 +3494,16 @@ export class SyncClient {
|
|
|
3511
3494
|
const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
3512
3495
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3513
3496
|
this.#applyBatch((batch) => {
|
|
3514
|
-
saveSubscription(this.#db, {
|
|
3497
|
+
saveSubscription(this.#db, definedObject({
|
|
3515
3498
|
id: sub.id,
|
|
3516
3499
|
table: sub.table,
|
|
3517
3500
|
scopes: sub.scopes,
|
|
3518
|
-
|
|
3501
|
+
params: sub.params,
|
|
3519
3502
|
cursor: nextCursor,
|
|
3520
|
-
|
|
3503
|
+
bootstrapState: bootstrapState,
|
|
3521
3504
|
effectiveScopes: start.effectiveScopes,
|
|
3522
3505
|
status: 'active',
|
|
3523
|
-
});
|
|
3506
|
+
}));
|
|
3524
3507
|
if (completed && registered !== undefined) {
|
|
3525
3508
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
3526
3509
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -3534,18 +3517,16 @@ export class SyncClient {
|
|
|
3534
3517
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
3535
3518
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3536
3519
|
this.#applyBatch((batch) => {
|
|
3537
|
-
saveSubscription(this.#db, {
|
|
3520
|
+
saveSubscription(this.#db, definedObject({
|
|
3538
3521
|
id: sub.id,
|
|
3539
3522
|
table: sub.table,
|
|
3540
3523
|
scopes: sub.scopes,
|
|
3541
|
-
|
|
3524
|
+
params: sub.params,
|
|
3542
3525
|
cursor: -1,
|
|
3543
|
-
|
|
3544
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3545
|
-
: {}),
|
|
3526
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3546
3527
|
status: 'active',
|
|
3547
3528
|
reasonCode: start.reasonCode,
|
|
3548
|
-
});
|
|
3529
|
+
}));
|
|
3549
3530
|
if (registered !== undefined) {
|
|
3550
3531
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3551
3532
|
}
|
|
@@ -3567,6 +3548,10 @@ export class SyncClient {
|
|
|
3567
3548
|
const droppedRetained = dropFailedRows(this.#db, (name, values) => name === table.name &&
|
|
3568
3549
|
Object.entries(lastEffective).every(([variable, allowed]) => allowed.includes(String(values[table.scopeColumnByVariable.get(variable)]))));
|
|
3569
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);
|
|
3570
3555
|
for (const operation of commit.operations)
|
|
3571
3556
|
batch.table(operation.table);
|
|
3572
3557
|
batch.outcomes();
|
|
@@ -3620,18 +3605,16 @@ export class SyncClient {
|
|
|
3620
3605
|
}
|
|
3621
3606
|
}
|
|
3622
3607
|
}
|
|
3623
|
-
saveSubscription(this.#db, {
|
|
3608
|
+
saveSubscription(this.#db, definedObject({
|
|
3624
3609
|
id: sub.id,
|
|
3625
3610
|
table: sub.table,
|
|
3626
3611
|
scopes: sub.scopes,
|
|
3627
|
-
|
|
3612
|
+
params: sub.params,
|
|
3628
3613
|
cursor: nextCursor,
|
|
3629
|
-
|
|
3630
|
-
? { effectiveScopes: lastEffective }
|
|
3631
|
-
: {}),
|
|
3614
|
+
effectiveScopes: lastEffective,
|
|
3632
3615
|
status: failed ? 'failed' : 'revoked',
|
|
3633
3616
|
reasonCode: start.reasonCode,
|
|
3634
|
-
});
|
|
3617
|
+
}));
|
|
3635
3618
|
if (registered !== undefined) {
|
|
3636
3619
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3637
3620
|
}
|
|
@@ -3650,7 +3633,7 @@ export class SyncClient {
|
|
|
3650
3633
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
3651
3634
|
quoteIdent(SYNC_VERSION_COLUMN),
|
|
3652
3635
|
];
|
|
3653
|
-
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];
|
|
3654
3637
|
if (row === undefined)
|
|
3655
3638
|
return { opIndex, existed: false };
|
|
3656
3639
|
const values = {};
|
|
@@ -3690,7 +3673,7 @@ export class SyncClient {
|
|
|
3690
3673
|
const table = this.#schema.tables.get(operation.table);
|
|
3691
3674
|
if (table === undefined)
|
|
3692
3675
|
continue;
|
|
3693
|
-
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];
|
|
3694
3677
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
3695
3678
|
batch.table(table.name);
|
|
3696
3679
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
@@ -3732,7 +3715,7 @@ export class SyncClient {
|
|
|
3732
3715
|
this.#applyOperationsLocally(affectedOperations, batch);
|
|
3733
3716
|
}
|
|
3734
3717
|
}
|
|
3735
|
-
#applyOperationsLocally(operations, batch) {
|
|
3718
|
+
#applyOperationsLocally(operations, batch, replay = false) {
|
|
3736
3719
|
for (const op of operations) {
|
|
3737
3720
|
const table = this.#table(op.table);
|
|
3738
3721
|
let precise = batch === undefined
|
|
@@ -3750,7 +3733,7 @@ export class SyncClient {
|
|
|
3750
3733
|
// sync.row_missing, and the local mirror never invents a base.
|
|
3751
3734
|
const opValues = op.values ?? {};
|
|
3752
3735
|
const full = table.columns.every((column) => column.name in opValues);
|
|
3753
|
-
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];
|
|
3754
3737
|
if (local === undefined && !full) {
|
|
3755
3738
|
if (batch !== undefined && !precise)
|
|
3756
3739
|
batch.table(op.table);
|
|
@@ -3781,6 +3764,13 @@ export class SyncClient {
|
|
|
3781
3764
|
const version = local === undefined
|
|
3782
3765
|
? OPTIMISTIC_VERSION
|
|
3783
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;
|
|
3784
3774
|
upsertLocalRow(this.#db, table, values, version, false);
|
|
3785
3775
|
}
|
|
3786
3776
|
}
|
|
@@ -3797,7 +3787,7 @@ export class SyncClient {
|
|
|
3797
3787
|
restoreFailedBases(this.#db, this.#schema);
|
|
3798
3788
|
restoreFailedBases(this.#db, this.#schema, true);
|
|
3799
3789
|
for (const commit of pending)
|
|
3800
|
-
this.#applyOperationsLocally(commit.operations, batch);
|
|
3790
|
+
this.#applyOperationsLocally(commit.operations, batch, true);
|
|
3801
3791
|
});
|
|
3802
3792
|
});
|
|
3803
3793
|
}
|
|
@@ -3826,3 +3816,15 @@ export class SyncClient {
|
|
|
3826
3816
|
}
|
|
3827
3817
|
}
|
|
3828
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
|
+
}
|
|
3826
|
+
function requireRowId(value) {
|
|
3827
|
+
if (typeof value !== 'string' || !value.length)
|
|
3828
|
+
throw invalidRequest('a mutation requires a non-empty string primary key');
|
|
3829
|
+
return value;
|
|
3830
|
+
}
|