@syncular/client 0.30.10 → 0.30.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
|
+
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
2
3
|
import { ProgressEmitter, } from './progress.js';
|
|
3
4
|
/**
|
|
4
5
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
@@ -11,7 +12,7 @@ import { ProgressEmitter, } from './progress.js';
|
|
|
11
12
|
*/
|
|
12
13
|
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
13
14
|
import { EncryptError } from '@syncular/core';
|
|
14
|
-
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
15
|
+
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
15
16
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
16
17
|
import { registerDevtools } from './devtools.js';
|
|
17
18
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
|
|
@@ -25,8 +26,8 @@ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encod
|
|
|
25
26
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
26
27
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
27
28
|
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
|
-
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 { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
30
|
+
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
30
31
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
31
32
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
32
33
|
export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
|
|
@@ -221,7 +222,7 @@ export class SyncClient {
|
|
|
221
222
|
#preflightBarrier;
|
|
222
223
|
constructor(config) {
|
|
223
224
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
224
|
-
throw invalidRequest('securityPreflight and encryption are mutually exclusive;
|
|
225
|
+
throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
|
|
225
226
|
}
|
|
226
227
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
227
228
|
this.#db = config.database;
|
|
@@ -287,7 +288,7 @@ export class SyncClient {
|
|
|
287
288
|
persisted !== this.#config.clientId) {
|
|
288
289
|
await this.#lease.release();
|
|
289
290
|
this.#lease = undefined;
|
|
290
|
-
throw new ClientSyncError('client.identity_mismatch', `
|
|
291
|
+
throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
|
|
291
292
|
}
|
|
292
293
|
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
293
294
|
if (persisted === undefined) {
|
|
@@ -309,7 +310,7 @@ export class SyncClient {
|
|
|
309
310
|
this.#reconcilePreviousVersionAtBoot();
|
|
310
311
|
// A registration remains app intent only while its table exists in the
|
|
311
312
|
// running schema. Removed-table registrations would poison every pull.
|
|
312
|
-
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db),
|
|
313
|
+
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
|
|
313
314
|
this.#started = true;
|
|
314
315
|
// A persisted active subscription needs one catch-up round on every open:
|
|
315
316
|
// realtime only covers changes after the socket connects, and an
|
|
@@ -319,6 +320,7 @@ export class SyncClient {
|
|
|
319
320
|
// an application-issued sync() call.
|
|
320
321
|
const startupWork = this.#schemaFloor === undefined &&
|
|
321
322
|
(countOutbox(this.#db) > 0 ||
|
|
323
|
+
this.#db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL LIMIT 1`).length > 0 ||
|
|
322
324
|
loadPendingEvictions(this.#db).length > 0 ||
|
|
323
325
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
324
326
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
@@ -355,6 +357,7 @@ export class SyncClient {
|
|
|
355
357
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
356
358
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
357
359
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
360
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
358
361
|
});
|
|
359
362
|
return;
|
|
360
363
|
}
|
|
@@ -366,6 +369,7 @@ export class SyncClient {
|
|
|
366
369
|
this.#db.transaction(() => {
|
|
367
370
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
368
371
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
372
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
369
373
|
});
|
|
370
374
|
return;
|
|
371
375
|
}
|
|
@@ -393,10 +397,13 @@ export class SyncClient {
|
|
|
393
397
|
if (this.#previousVersion !== undefined) {
|
|
394
398
|
writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
|
|
395
399
|
}
|
|
400
|
+
pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
|
|
401
|
+
this.#discardAcknowledgedRows();
|
|
396
402
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
397
403
|
resetSubscriptionsForBump(this.#db);
|
|
398
404
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
399
405
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
406
|
+
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
400
407
|
});
|
|
401
408
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
402
409
|
for (const table of this.#schema.tables.values())
|
|
@@ -473,6 +480,7 @@ export class SyncClient {
|
|
|
473
480
|
this.#sweepPreviousVersionContainer();
|
|
474
481
|
this.#applyBatch((batch) => {
|
|
475
482
|
this.#db.transaction(() => {
|
|
483
|
+
this.#discardAcknowledgedRows();
|
|
476
484
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
477
485
|
resetSubscriptionsForBump(this.#db);
|
|
478
486
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -709,13 +717,13 @@ export class SyncClient {
|
|
|
709
717
|
previous.sqliteCode !== sqliteCode ||
|
|
710
718
|
previous.tables.join('\0') !== tables.join('\0')) {
|
|
711
719
|
this.#queryFailures.delete(owner.id);
|
|
712
|
-
this.#queryFailures.set(owner.id, {
|
|
720
|
+
this.#queryFailures.set(owner.id, definedObject({
|
|
713
721
|
id: owner.id,
|
|
714
722
|
tables,
|
|
715
723
|
code,
|
|
716
|
-
|
|
724
|
+
sqliteCode: sqliteCode,
|
|
717
725
|
atMs: this.#now(),
|
|
718
|
-
});
|
|
726
|
+
}));
|
|
719
727
|
for (const id of this.#queryFailures.keys()) {
|
|
720
728
|
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
721
729
|
break;
|
|
@@ -1036,22 +1044,18 @@ export class SyncClient {
|
|
|
1036
1044
|
}
|
|
1037
1045
|
return a.id.localeCompare(b.id);
|
|
1038
1046
|
});
|
|
1039
|
-
return {
|
|
1047
|
+
return definedObject({
|
|
1040
1048
|
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1041
1049
|
capturedAtMs,
|
|
1042
|
-
host: {
|
|
1050
|
+
host: definedObject({
|
|
1043
1051
|
kind: 'direct',
|
|
1044
1052
|
role: 'single',
|
|
1045
1053
|
connectivity,
|
|
1046
1054
|
realtime: this.#realtimeState,
|
|
1047
1055
|
realtimePolicy: this.#realtimePolicy,
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
...(this.#realtimeRetryDelayMs !== undefined
|
|
1052
|
-
? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
|
|
1053
|
-
: {}),
|
|
1054
|
-
},
|
|
1056
|
+
realtimeReasonCode: this.#realtimeReasonCode,
|
|
1057
|
+
realtimeRetryDelayMs: this.#realtimeRetryDelayMs,
|
|
1058
|
+
}),
|
|
1055
1059
|
securityLifecycle: this.#securityLifecycle,
|
|
1056
1060
|
schema: {
|
|
1057
1061
|
currentVersion: this.#config.schema.version,
|
|
@@ -1071,24 +1075,20 @@ export class SyncClient {
|
|
|
1071
1075
|
lease: leaseState,
|
|
1072
1076
|
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
1073
1077
|
subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
? { lastChange: this.#lastChange }
|
|
1077
|
-
: {}),
|
|
1078
|
+
lastRound: this.#lastRound,
|
|
1079
|
+
lastChange: this.#lastChange,
|
|
1078
1080
|
storage: this.#diagnosticStorage(),
|
|
1079
1081
|
queryFailures: [...this.#queryFailures.values()],
|
|
1080
|
-
};
|
|
1082
|
+
});
|
|
1081
1083
|
}
|
|
1082
1084
|
#diagnosticLease(nowMs) {
|
|
1083
1085
|
const lease = this.#leaseState;
|
|
1084
1086
|
if (lease?.errorCode !== undefined) {
|
|
1085
|
-
return {
|
|
1087
|
+
return definedObject({
|
|
1086
1088
|
state: 'stopped',
|
|
1087
1089
|
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
: {}),
|
|
1091
|
-
};
|
|
1090
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1091
|
+
});
|
|
1092
1092
|
}
|
|
1093
1093
|
if (lease?.expiresAtMs === undefined)
|
|
1094
1094
|
return { state: 'none' };
|
|
@@ -1101,12 +1101,10 @@ export class SyncClient {
|
|
|
1101
1101
|
try {
|
|
1102
1102
|
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
1103
1103
|
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];
|
|
1104
|
+
const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
|
|
1105
|
+
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
1106
|
const blobBytes = this.#hasBlobs
|
|
1109
|
-
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),
|
|
1107
|
+
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
1110
1108
|
: 0;
|
|
1111
1109
|
const pressure = this.#config.blobCacheMaxBytes !== undefined &&
|
|
1112
1110
|
blobBytes > this.#config.blobCacheMaxBytes;
|
|
@@ -1185,7 +1183,7 @@ export class SyncClient {
|
|
|
1185
1183
|
try {
|
|
1186
1184
|
result = fn(batch);
|
|
1187
1185
|
if (this.#db
|
|
1188
|
-
.query(
|
|
1186
|
+
.query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
|
|
1189
1187
|
.some((row) => batch.hasTable(String(row.tbl))))
|
|
1190
1188
|
batch.outcomes();
|
|
1191
1189
|
if (batch.touched) {
|
|
@@ -1460,7 +1458,7 @@ export class SyncClient {
|
|
|
1460
1458
|
const priorRejections = this.#rejections;
|
|
1461
1459
|
return this.#applyBatch((batch) => {
|
|
1462
1460
|
restoreFailedBases(this.#db, this.#schema);
|
|
1463
|
-
this.#db.exec(
|
|
1461
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
1464
1462
|
input.clientCommitId,
|
|
1465
1463
|
]);
|
|
1466
1464
|
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
@@ -1575,18 +1573,18 @@ export class SyncClient {
|
|
|
1575
1573
|
canonicalScopeJson(input.scopes) &&
|
|
1576
1574
|
existing.params === input.params;
|
|
1577
1575
|
if (!sameIntent) {
|
|
1578
|
-
throw new ClientSyncError('client.subscription_intent_mismatch', '
|
|
1576
|
+
throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
|
|
1579
1577
|
}
|
|
1580
1578
|
return;
|
|
1581
1579
|
}
|
|
1582
|
-
saveSubscription(this.#db, {
|
|
1580
|
+
saveSubscription(this.#db, definedObject({
|
|
1583
1581
|
id: input.id,
|
|
1584
1582
|
table: input.table,
|
|
1585
1583
|
scopes: input.scopes,
|
|
1586
|
-
|
|
1584
|
+
params: input.params,
|
|
1587
1585
|
cursor: -1,
|
|
1588
1586
|
status: 'active',
|
|
1589
|
-
});
|
|
1587
|
+
}));
|
|
1590
1588
|
this.#setSyncNeeded(true);
|
|
1591
1589
|
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1592
1590
|
this.#emitDiagnostics();
|
|
@@ -1622,7 +1620,7 @@ export class SyncClient {
|
|
|
1622
1620
|
this.#requireActive();
|
|
1623
1621
|
const table = this.#table(base.table);
|
|
1624
1622
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
1625
|
-
throw invalidRequest(
|
|
1623
|
+
throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
|
|
1626
1624
|
}
|
|
1627
1625
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
1628
1626
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
@@ -1644,14 +1642,14 @@ export class SyncClient {
|
|
|
1644
1642
|
// Re-entry cancels any deferred eviction for this sub id.
|
|
1645
1643
|
deletePendingEviction(this.#db, subId);
|
|
1646
1644
|
insertWindowUnit(this.#db, baseKey, unit, subId);
|
|
1647
|
-
saveSubscription(this.#db, {
|
|
1645
|
+
saveSubscription(this.#db, definedObject({
|
|
1648
1646
|
id: subId,
|
|
1649
1647
|
table: base.table,
|
|
1650
1648
|
scopes: unitScopes(base, unit),
|
|
1651
|
-
|
|
1649
|
+
params: base.params,
|
|
1652
1650
|
cursor: -1,
|
|
1653
1651
|
status: 'active',
|
|
1654
|
-
});
|
|
1652
|
+
}));
|
|
1655
1653
|
});
|
|
1656
1654
|
this.#needsPull = true;
|
|
1657
1655
|
batch.window(baseKey, base.table, unit);
|
|
@@ -1809,14 +1807,12 @@ export class SyncClient {
|
|
|
1809
1807
|
const operations = mutations.map((mutation) => {
|
|
1810
1808
|
const table = this.#table(mutation.table);
|
|
1811
1809
|
if (mutation.op === 'delete') {
|
|
1812
|
-
return {
|
|
1810
|
+
return definedObject({
|
|
1813
1811
|
table: mutation.table,
|
|
1814
1812
|
rowId: mutation.rowId,
|
|
1815
1813
|
op: 'delete',
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
: {}),
|
|
1819
|
-
};
|
|
1814
|
+
baseVersion: mutation.baseVersion,
|
|
1815
|
+
});
|
|
1820
1816
|
}
|
|
1821
1817
|
const json = {};
|
|
1822
1818
|
if (mutation.op === 'patch') {
|
|
@@ -1828,7 +1824,7 @@ export class SyncClient {
|
|
|
1828
1824
|
const scopeColumns = new Set(table.scopeColumnByVariable.values());
|
|
1829
1825
|
const pkColumn = table.columns[table.primaryKeyIndex];
|
|
1830
1826
|
const pkValue = requireRowId(normalized.get(pkColumn.name));
|
|
1831
|
-
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
1827
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [pkValue])[0];
|
|
1832
1828
|
if (!local)
|
|
1833
1829
|
throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
|
|
1834
1830
|
for (const column of table.columns) {
|
|
@@ -1868,15 +1864,13 @@ export class SyncClient {
|
|
|
1868
1864
|
});
|
|
1869
1865
|
}
|
|
1870
1866
|
const pkValue = requireRowId(json[table.primaryKey]);
|
|
1871
|
-
return {
|
|
1867
|
+
return definedObject({
|
|
1872
1868
|
table: mutation.table,
|
|
1873
1869
|
rowId: pkValue,
|
|
1874
1870
|
op: 'upsert',
|
|
1875
|
-
|
|
1876
|
-
? { baseVersion: mutation.baseVersion }
|
|
1877
|
-
: {}),
|
|
1871
|
+
baseVersion: mutation.baseVersion,
|
|
1878
1872
|
values: json,
|
|
1879
|
-
};
|
|
1873
|
+
});
|
|
1880
1874
|
});
|
|
1881
1875
|
this.#applyBatch((batch) => {
|
|
1882
1876
|
this.#db.transaction(() => {
|
|
@@ -1900,10 +1894,7 @@ export class SyncClient {
|
|
|
1900
1894
|
catch {
|
|
1901
1895
|
continue;
|
|
1902
1896
|
}
|
|
1903
|
-
this.#db.exec(
|
|
1904
|
-
SELECT ?, ? WHERE EXISTS (
|
|
1905
|
-
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
1906
|
-
)`, [clientCommitId, blobId, blobId]);
|
|
1897
|
+
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
1898
|
}
|
|
1908
1899
|
}
|
|
1909
1900
|
try {
|
|
@@ -1990,17 +1981,13 @@ export class SyncClient {
|
|
|
1990
1981
|
// in the running schema), so purge drops it as a fixed step, before any
|
|
1991
1982
|
// mirror row is touched and unconditionally.
|
|
1992
1983
|
this.#dropPreviousVersion();
|
|
1993
|
-
const
|
|
1984
|
+
const priorRejections = [...this.#rejections];
|
|
1985
|
+
const priorConflicts = [...this.#conflicts];
|
|
1994
1986
|
try {
|
|
1995
1987
|
return this.#applyBatch((batch) => {
|
|
1996
1988
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1997
1989
|
restoreFailedBases(this.#db, this.#schema);
|
|
1998
|
-
|
|
1999
|
-
for (const commit of droppedRetained) {
|
|
2000
|
-
for (const operation of commit.operations)
|
|
2001
|
-
batch.table(operation.table);
|
|
2002
|
-
batch.outcomes();
|
|
2003
|
-
}
|
|
1990
|
+
this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
2004
1991
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
2005
1992
|
// a commit is doomed when any of its ops touches a base-matching
|
|
2006
1993
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -2053,7 +2040,7 @@ export class SyncClient {
|
|
|
2053
2040
|
batch.table(tableName);
|
|
2054
2041
|
for (let offset = 0; offset < values.length; offset += 400) {
|
|
2055
2042
|
const chunk = values.slice(offset, offset + 400);
|
|
2056
|
-
this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN
|
|
2043
|
+
this.#db.exec(`DELETE FROM ${quoteIdent(tableName)} WHERE ${quoteIdent(table.primaryKey)} IN(${chunk.map(() => '?').join(', ')})`, chunk);
|
|
2057
2044
|
}
|
|
2058
2045
|
}
|
|
2059
2046
|
if (doomed.length > 0) {
|
|
@@ -2096,7 +2083,8 @@ export class SyncClient {
|
|
|
2096
2083
|
catch (error) {
|
|
2097
2084
|
// SQLite rolls back through #applyBatch; mirror that rollback for the
|
|
2098
2085
|
// in-memory rejection cache before surfacing the storage failure.
|
|
2099
|
-
this.#rejections
|
|
2086
|
+
this.#rejections = priorRejections;
|
|
2087
|
+
this.#conflicts = priorConflicts;
|
|
2100
2088
|
throw error;
|
|
2101
2089
|
}
|
|
2102
2090
|
}
|
|
@@ -2135,6 +2123,7 @@ export class SyncClient {
|
|
|
2135
2123
|
this.#upgrading = true;
|
|
2136
2124
|
this.#needsPull = true;
|
|
2137
2125
|
batch.status();
|
|
2126
|
+
this.#discardAcknowledgedRows();
|
|
2138
2127
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
2139
2128
|
resetSubscriptionsForBump(this.#db);
|
|
2140
2129
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -2186,7 +2175,7 @@ export class SyncClient {
|
|
|
2186
2175
|
clauses.push(`${quoteIdent(selector.column)} IN (${selector.values.map(() => '?').join(', ')})`);
|
|
2187
2176
|
params.push(...selector.values);
|
|
2188
2177
|
}
|
|
2189
|
-
for (const row of this.#db.query(`SELECT CAST(${quoteIdent(target.table.primaryKey)} AS TEXT)
|
|
2178
|
+
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
2179
|
if (typeof row.id === 'string')
|
|
2191
2180
|
ids.add(row.id);
|
|
2192
2181
|
}
|
|
@@ -2228,7 +2217,7 @@ export class SyncClient {
|
|
|
2228
2217
|
async #encodeOutboxForPush() {
|
|
2229
2218
|
// Pin before the first encryption await: mutations can append while a
|
|
2230
2219
|
// round is encoding, and belong to the next request.
|
|
2231
|
-
const bounds = this.#db.query('SELECT COUNT(*)
|
|
2220
|
+
const bounds = this.#db.query('SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM _syncular_outbox')[0];
|
|
2232
2221
|
const pendingCount = bounds.count;
|
|
2233
2222
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2234
2223
|
const pushFrames = [];
|
|
@@ -2258,7 +2247,7 @@ export class SyncClient {
|
|
|
2258
2247
|
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2259
2248
|
if (selector === undefined)
|
|
2260
2249
|
return undefined;
|
|
2261
|
-
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
2250
|
+
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
|
|
2262
2251
|
const value = row?.value;
|
|
2263
2252
|
return typeof value === 'string' && value.length > 0
|
|
2264
2253
|
? value
|
|
@@ -2351,15 +2340,13 @@ export class SyncClient {
|
|
|
2351
2340
|
const error = classifySqliteFailure(caught).error;
|
|
2352
2341
|
const completedAtMs = this.#now();
|
|
2353
2342
|
const code = error.code;
|
|
2354
|
-
this.#progress.update({
|
|
2343
|
+
this.#progress.update(definedObject({
|
|
2355
2344
|
state: 'failed',
|
|
2356
2345
|
errorCode: typeof code === 'string'
|
|
2357
2346
|
? this.#diagnosticCode(code)
|
|
2358
2347
|
: 'client.unknown_failure',
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
: {}),
|
|
2362
|
-
});
|
|
2348
|
+
retryDelayMs: this.#roundRetryDelayMs,
|
|
2349
|
+
}));
|
|
2363
2350
|
this.#lastRound = {
|
|
2364
2351
|
status: 'failed',
|
|
2365
2352
|
startedAtMs,
|
|
@@ -2451,12 +2438,12 @@ export class SyncClient {
|
|
|
2451
2438
|
const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
|
|
2452
2439
|
const limits = this.#config.limits;
|
|
2453
2440
|
const frames = [
|
|
2454
|
-
{
|
|
2441
|
+
definedObject({
|
|
2455
2442
|
type: 'REQ_HEADER',
|
|
2456
2443
|
clientId: this.#clientId,
|
|
2457
2444
|
schemaVersion: this.#schema.version,
|
|
2458
|
-
|
|
2459
|
-
},
|
|
2445
|
+
logEpoch: logEpoch,
|
|
2446
|
+
}),
|
|
2460
2447
|
...pushFrames,
|
|
2461
2448
|
{
|
|
2462
2449
|
type: 'PULL_HEADER',
|
|
@@ -2465,16 +2452,14 @@ export class SyncClient {
|
|
|
2465
2452
|
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
2466
2453
|
accept: this.#acceptMask(),
|
|
2467
2454
|
},
|
|
2468
|
-
...subs.map((sub) => ({
|
|
2455
|
+
...subs.map((sub) => definedObject({
|
|
2469
2456
|
type: 'SUBSCRIPTION',
|
|
2470
2457
|
id: sub.id,
|
|
2471
2458
|
table: sub.table,
|
|
2472
2459
|
scopes: sub.scopes,
|
|
2473
|
-
|
|
2460
|
+
params: sub.params,
|
|
2474
2461
|
cursor: sub.cursor,
|
|
2475
|
-
|
|
2476
|
-
? { bootstrapState: sub.bootstrapState }
|
|
2477
|
-
: {}),
|
|
2462
|
+
bootstrapState: sub.bootstrapState,
|
|
2478
2463
|
})),
|
|
2479
2464
|
];
|
|
2480
2465
|
const requestBytes = encodeMessage({
|
|
@@ -2925,12 +2910,10 @@ export class SyncClient {
|
|
|
2925
2910
|
// version this client sends. The §7.4.2 trigger-2 convergence runs
|
|
2926
2911
|
// when the APP updates (recreating the client with a new generated
|
|
2927
2912
|
// schema), which fires the boot-time §7.4.1 marker check instead.
|
|
2928
|
-
const schemaFloor = {
|
|
2913
|
+
const schemaFloor = definedObject({
|
|
2929
2914
|
requiredSchemaVersion: header.requiredSchemaVersion,
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
: {}),
|
|
2933
|
-
};
|
|
2915
|
+
latestSchemaVersion: header.latestSchemaVersion,
|
|
2916
|
+
});
|
|
2934
2917
|
this.#setSchemaFloor(schemaFloor);
|
|
2935
2918
|
return {
|
|
2936
2919
|
...summary,
|
|
@@ -2985,6 +2968,7 @@ export class SyncClient {
|
|
|
2985
2968
|
index += 1;
|
|
2986
2969
|
}
|
|
2987
2970
|
}
|
|
2971
|
+
const previousNeedsPull = this.#needsPull;
|
|
2988
2972
|
const conflictCount = this.#conflicts.length;
|
|
2989
2973
|
const rejectionCount = this.#rejections.length;
|
|
2990
2974
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -3001,9 +2985,10 @@ export class SyncClient {
|
|
|
3001
2985
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3002
2986
|
}
|
|
3003
2987
|
}, () => this.#statusSnapshot(outboxCount), (cause) => {
|
|
2988
|
+
this.#needsPull = previousNeedsPull;
|
|
3004
2989
|
this.#conflicts.length = conflictCount;
|
|
3005
2990
|
this.#rejections.length = rejectionCount;
|
|
3006
|
-
const error = new ClientSyncError('client.outcome_persistence_failed', '
|
|
2991
|
+
const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
|
|
3007
2992
|
error.cause = cause;
|
|
3008
2993
|
throw error;
|
|
3009
2994
|
});
|
|
@@ -3214,6 +3199,10 @@ export class SyncClient {
|
|
|
3214
3199
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3215
3200
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3216
3201
|
// "already applied, you may have missed the ack".
|
|
3202
|
+
if (frame.commitSeq === undefined)
|
|
3203
|
+
throw new ClientSyncError('client.invalid_host_response', 'ACK lacks a commit sequence');
|
|
3204
|
+
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId), frame.commitSeq);
|
|
3205
|
+
this.#setSyncNeeded(true);
|
|
3217
3206
|
recordCommitOutcome(this.#db, {
|
|
3218
3207
|
clientCommitId: frame.clientCommitId,
|
|
3219
3208
|
status: frame.status,
|
|
@@ -3243,7 +3232,7 @@ export class SyncClient {
|
|
|
3243
3232
|
for (const result of frame.results) {
|
|
3244
3233
|
const operation = commit.operations[result.opIndex];
|
|
3245
3234
|
if (result.status === 'conflict') {
|
|
3246
|
-
const conflict = {
|
|
3235
|
+
const conflict = definedObject({
|
|
3247
3236
|
clientCommitId: frame.clientCommitId,
|
|
3248
3237
|
opIndex: result.opIndex,
|
|
3249
3238
|
table: operation?.table ?? '',
|
|
@@ -3253,8 +3242,8 @@ export class SyncClient {
|
|
|
3253
3242
|
serverVersion: result.serverVersion,
|
|
3254
3243
|
serverRow: this.#decodeServerRow(operation?.table, result.serverRow),
|
|
3255
3244
|
conflictColumns: this.#decodeConflictColumns(operation?.table, result.conflictColumns),
|
|
3256
|
-
|
|
3257
|
-
};
|
|
3245
|
+
operation: operation,
|
|
3246
|
+
});
|
|
3258
3247
|
this.#conflicts.push(conflict);
|
|
3259
3248
|
outcomeResults.push({ status: 'conflict', conflict });
|
|
3260
3249
|
batch.conflicts();
|
|
@@ -3262,15 +3251,15 @@ export class SyncClient {
|
|
|
3262
3251
|
}
|
|
3263
3252
|
else if (result.status === 'error') {
|
|
3264
3253
|
const details = rejectionDetails?.get(result.opIndex);
|
|
3265
|
-
const rejection = {
|
|
3254
|
+
const rejection = definedObject({
|
|
3266
3255
|
clientCommitId: frame.clientCommitId,
|
|
3267
3256
|
opIndex: result.opIndex,
|
|
3268
3257
|
code: result.code,
|
|
3269
3258
|
message: result.message,
|
|
3270
3259
|
retryable: result.retryable,
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
};
|
|
3260
|
+
details: details,
|
|
3261
|
+
operation: operation,
|
|
3262
|
+
});
|
|
3274
3263
|
this.#rejections.push(rejection);
|
|
3275
3264
|
outcomeResults.push({ status: 'error', rejection });
|
|
3276
3265
|
batch.rejections();
|
|
@@ -3302,7 +3291,7 @@ export class SyncClient {
|
|
|
3302
3291
|
return true;
|
|
3303
3292
|
}
|
|
3304
3293
|
#outboxCommitExists(clientCommitId) {
|
|
3305
|
-
return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id
|
|
3294
|
+
return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id=? LIMIT 1', [clientCommitId]).length > 0);
|
|
3306
3295
|
}
|
|
3307
3296
|
#decodeServerRow(tableName, payload) {
|
|
3308
3297
|
if (tableName === undefined)
|
|
@@ -3339,7 +3328,9 @@ export class SyncClient {
|
|
|
3339
3328
|
async #applyCommit(frame, summary) {
|
|
3340
3329
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3341
3330
|
this.#recordCommitChanges(batch, frame);
|
|
3342
|
-
|
|
3331
|
+
const result = fn();
|
|
3332
|
+
this.#replayOutbox(result);
|
|
3333
|
+
return result;
|
|
3343
3334
|
}));
|
|
3344
3335
|
summary.commitsApplied += 1;
|
|
3345
3336
|
}
|
|
@@ -3367,9 +3358,7 @@ export class SyncClient {
|
|
|
3367
3358
|
const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
|
|
3368
3359
|
if (mappings.length === 0)
|
|
3369
3360
|
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];
|
|
3361
|
+
const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
|
|
3373
3362
|
if (row === undefined)
|
|
3374
3363
|
return false;
|
|
3375
3364
|
let recorded = false;
|
|
@@ -3385,20 +3374,9 @@ export class SyncClient {
|
|
|
3385
3374
|
}
|
|
3386
3375
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
3387
3376
|
#scopedRowsExist(table, effective) {
|
|
3388
|
-
const
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
const clauses = [];
|
|
3392
|
-
const params = [];
|
|
3393
|
-
for (const [variable, values] of entries) {
|
|
3394
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
3395
|
-
if (column === undefined || values.length === 0)
|
|
3396
|
-
return false;
|
|
3397
|
-
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
3398
|
-
params.push(...values);
|
|
3399
|
-
}
|
|
3400
|
-
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
|
|
3401
|
-
WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
|
|
3377
|
+
const scope = localScopePredicate(table, effective);
|
|
3378
|
+
return (scope !== undefined &&
|
|
3379
|
+
this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${scope.sql} LIMIT 1`, scope.params).length > 0);
|
|
3402
3380
|
}
|
|
3403
3381
|
/**
|
|
3404
3382
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -3419,18 +3397,16 @@ export class SyncClient {
|
|
|
3419
3397
|
error.code === 'sync.scope_revoked') {
|
|
3420
3398
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3421
3399
|
this.#applyBatch((batch) => {
|
|
3422
|
-
saveSubscription(this.#db, {
|
|
3400
|
+
saveSubscription(this.#db, definedObject({
|
|
3423
3401
|
id: sub.id,
|
|
3424
3402
|
table: sub.table,
|
|
3425
3403
|
scopes: sub.scopes,
|
|
3426
|
-
|
|
3404
|
+
params: sub.params,
|
|
3427
3405
|
cursor: sub.cursor,
|
|
3428
|
-
|
|
3429
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3430
|
-
: {}),
|
|
3406
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3431
3407
|
status: 'failed',
|
|
3432
3408
|
reasonCode: 'sync.scope_revoked',
|
|
3433
|
-
});
|
|
3409
|
+
}));
|
|
3434
3410
|
if (registered !== undefined) {
|
|
3435
3411
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3436
3412
|
}
|
|
@@ -3474,7 +3450,7 @@ export class SyncClient {
|
|
|
3474
3450
|
if (frame.urlExpiresAtMs !== undefined &&
|
|
3475
3451
|
frame.urlExpiresAtMs <= this.#now()) {
|
|
3476
3452
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
3477
|
-
throw new ClientSyncError('sync.segment_expired', `
|
|
3453
|
+
throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
|
|
3478
3454
|
}
|
|
3479
3455
|
bytes = await fetchUrl(frame.url, onProgress);
|
|
3480
3456
|
}
|
|
@@ -3507,16 +3483,35 @@ export class SyncClient {
|
|
|
3507
3483
|
const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
|
|
3508
3484
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3509
3485
|
this.#applyBatch((batch) => {
|
|
3510
|
-
saveSubscription(this.#db, {
|
|
3486
|
+
saveSubscription(this.#db, definedObject({
|
|
3511
3487
|
id: sub.id,
|
|
3512
3488
|
table: sub.table,
|
|
3513
3489
|
scopes: sub.scopes,
|
|
3514
|
-
|
|
3490
|
+
params: sub.params,
|
|
3515
3491
|
cursor: nextCursor,
|
|
3516
|
-
|
|
3492
|
+
bootstrapState: bootstrapState,
|
|
3517
3493
|
effectiveScopes: start.effectiveScopes,
|
|
3518
3494
|
status: 'active',
|
|
3519
|
-
});
|
|
3495
|
+
}));
|
|
3496
|
+
if (completed) {
|
|
3497
|
+
const table = this.#table(sub.table);
|
|
3498
|
+
const effective = start.effectiveScopes;
|
|
3499
|
+
const covered = this.#db
|
|
3500
|
+
.query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq<=?`, [sub.table, nextCursor])
|
|
3501
|
+
.filter((row) => [row.intent, row.base].some((json) => {
|
|
3502
|
+
if (json === null)
|
|
3503
|
+
return false;
|
|
3504
|
+
const values = JSON.parse(String(json));
|
|
3505
|
+
return matchesLocalScopes(table, effective, values);
|
|
3506
|
+
}));
|
|
3507
|
+
if (covered.length) {
|
|
3508
|
+
restoreFailedBases(this.#db, this.#schema);
|
|
3509
|
+
for (const row of covered)
|
|
3510
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
|
|
3511
|
+
batch.table(sub.table);
|
|
3512
|
+
this.#replayOutbox();
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3520
3515
|
if (completed && registered !== undefined) {
|
|
3521
3516
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
3522
3517
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -3530,18 +3525,16 @@ export class SyncClient {
|
|
|
3530
3525
|
// with cursor = -1 on the next pull. Staleness, not a purge.
|
|
3531
3526
|
const registered = getWindowUnitBySubId(this.#db, sub.id);
|
|
3532
3527
|
this.#applyBatch((batch) => {
|
|
3533
|
-
saveSubscription(this.#db, {
|
|
3528
|
+
saveSubscription(this.#db, definedObject({
|
|
3534
3529
|
id: sub.id,
|
|
3535
3530
|
table: sub.table,
|
|
3536
3531
|
scopes: sub.scopes,
|
|
3537
|
-
|
|
3532
|
+
params: sub.params,
|
|
3538
3533
|
cursor: -1,
|
|
3539
|
-
|
|
3540
|
-
? { effectiveScopes: sub.effectiveScopes }
|
|
3541
|
-
: {}),
|
|
3534
|
+
effectiveScopes: sub.effectiveScopes,
|
|
3542
3535
|
status: 'active',
|
|
3543
3536
|
reasonCode: start.reasonCode,
|
|
3544
|
-
});
|
|
3537
|
+
}));
|
|
3545
3538
|
if (registered !== undefined) {
|
|
3546
3539
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3547
3540
|
}
|
|
@@ -3560,13 +3553,8 @@ export class SyncClient {
|
|
|
3560
3553
|
Object.keys(lastEffective).length > 0) {
|
|
3561
3554
|
try {
|
|
3562
3555
|
restoreFailedBases(this.#db, this.#schema);
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
for (const commit of droppedRetained) {
|
|
3566
|
-
for (const operation of commit.operations)
|
|
3567
|
-
batch.table(operation.table);
|
|
3568
|
-
batch.outcomes();
|
|
3569
|
-
}
|
|
3556
|
+
this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3557
|
+
matchesLocalScopes(table, lastEffective, values));
|
|
3570
3558
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3571
3559
|
batch.scopeMap(table, lastEffective);
|
|
3572
3560
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3616,18 +3604,16 @@ export class SyncClient {
|
|
|
3616
3604
|
}
|
|
3617
3605
|
}
|
|
3618
3606
|
}
|
|
3619
|
-
saveSubscription(this.#db, {
|
|
3607
|
+
saveSubscription(this.#db, definedObject({
|
|
3620
3608
|
id: sub.id,
|
|
3621
3609
|
table: sub.table,
|
|
3622
3610
|
scopes: sub.scopes,
|
|
3623
|
-
|
|
3611
|
+
params: sub.params,
|
|
3624
3612
|
cursor: nextCursor,
|
|
3625
|
-
|
|
3626
|
-
? { effectiveScopes: lastEffective }
|
|
3627
|
-
: {}),
|
|
3613
|
+
effectiveScopes: lastEffective,
|
|
3628
3614
|
status: failed ? 'failed' : 'revoked',
|
|
3629
3615
|
reasonCode: start.reasonCode,
|
|
3630
|
-
});
|
|
3616
|
+
}));
|
|
3631
3617
|
if (registered !== undefined) {
|
|
3632
3618
|
batch.window(registered.baseKey, sub.table, registered.unit);
|
|
3633
3619
|
}
|
|
@@ -3646,7 +3632,7 @@ export class SyncClient {
|
|
|
3646
3632
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
3647
3633
|
quoteIdent(SYNC_VERSION_COLUMN),
|
|
3648
3634
|
];
|
|
3649
|
-
const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3635
|
+
const row = this.#db.query(`SELECT ${columns.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
|
|
3650
3636
|
if (row === undefined)
|
|
3651
3637
|
return { opIndex, existed: false };
|
|
3652
3638
|
const values = {};
|
|
@@ -3686,7 +3672,7 @@ export class SyncClient {
|
|
|
3686
3672
|
const table = this.#schema.tables.get(operation.table);
|
|
3687
3673
|
if (table === undefined)
|
|
3688
3674
|
continue;
|
|
3689
|
-
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}
|
|
3675
|
+
const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId])[0];
|
|
3690
3676
|
if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
|
|
3691
3677
|
batch.table(table.name);
|
|
3692
3678
|
deleteLocalRow(this.#db, table, operation.rowId);
|
|
@@ -3728,7 +3714,7 @@ export class SyncClient {
|
|
|
3728
3714
|
this.#applyOperationsLocally(affectedOperations, batch);
|
|
3729
3715
|
}
|
|
3730
3716
|
}
|
|
3731
|
-
#applyOperationsLocally(operations, batch) {
|
|
3717
|
+
#applyOperationsLocally(operations, batch, replay = false) {
|
|
3732
3718
|
for (const op of operations) {
|
|
3733
3719
|
const table = this.#table(op.table);
|
|
3734
3720
|
let precise = batch === undefined
|
|
@@ -3746,7 +3732,7 @@ export class SyncClient {
|
|
|
3746
3732
|
// sync.row_missing, and the local mirror never invents a base.
|
|
3747
3733
|
const opValues = op.values ?? {};
|
|
3748
3734
|
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)}
|
|
3735
|
+
const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [op.rowId])[0];
|
|
3750
3736
|
if (local === undefined && !full) {
|
|
3751
3737
|
if (batch !== undefined && !precise)
|
|
3752
3738
|
batch.table(op.table);
|
|
@@ -3777,23 +3763,47 @@ export class SyncClient {
|
|
|
3777
3763
|
const version = local === undefined
|
|
3778
3764
|
? OPTIMISTIC_VERSION
|
|
3779
3765
|
: local[SYNC_VERSION_COLUMN];
|
|
3766
|
+
if (replay &&
|
|
3767
|
+
table.indexes.some((index) => index.unique) &&
|
|
3768
|
+
uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
|
|
3769
|
+
column.name,
|
|
3770
|
+
values[index] ?? null,
|
|
3771
|
+
]))).length)
|
|
3772
|
+
continue;
|
|
3780
3773
|
upsertLocalRow(this.#db, table, values, version, false);
|
|
3781
3774
|
}
|
|
3782
3775
|
}
|
|
3783
3776
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
3784
|
-
#
|
|
3777
|
+
#discardAcknowledgedRows() {
|
|
3778
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
3779
|
+
}
|
|
3780
|
+
#dropRetainedRows(batch, matches) {
|
|
3781
|
+
for (const commit of dropFailedRows(this.#db, matches)) {
|
|
3782
|
+
batch.conflicts();
|
|
3783
|
+
batch.rejections();
|
|
3784
|
+
batch.outcomes();
|
|
3785
|
+
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3786
|
+
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3787
|
+
for (const op of commit.operations)
|
|
3788
|
+
batch.table(op.table);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
#replayOutbox(rowKeys) {
|
|
3792
|
+
const acknowledged = failedOverlayCommits(this.#db, true, rowKeys);
|
|
3785
3793
|
const pending = [
|
|
3786
|
-
...failedOverlayCommits(this.#db),
|
|
3787
|
-
...listOutbox(this.#db),
|
|
3794
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3795
|
+
...listOutbox(this.#db, rowKeys),
|
|
3788
3796
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3789
|
-
if (pending.length === 0)
|
|
3797
|
+
if (pending.length === 0 && acknowledged.length === 0)
|
|
3790
3798
|
return;
|
|
3791
3799
|
this.#applyBatch((batch) => {
|
|
3792
3800
|
this.#db.transaction(() => {
|
|
3793
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
3794
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
3795
|
-
for (const commit of pending)
|
|
3796
|
-
this.#applyOperationsLocally(
|
|
3801
|
+
restoreFailedBases(this.#db, this.#schema, false, rowKeys);
|
|
3802
|
+
restoreFailedBases(this.#db, this.#schema, true, rowKeys);
|
|
3803
|
+
for (const commit of [...acknowledged, ...pending])
|
|
3804
|
+
this.#applyOperationsLocally(rowKeys === undefined
|
|
3805
|
+
? commit.operations
|
|
3806
|
+
: commit.operations.filter((op) => rowKeys.includes(JSON.stringify([op.table, op.rowId]))), batch, true);
|
|
3797
3807
|
});
|
|
3798
3808
|
});
|
|
3799
3809
|
}
|
|
@@ -3806,22 +3816,29 @@ export class SyncClient {
|
|
|
3806
3816
|
#table(name) {
|
|
3807
3817
|
const table = this.#schema.tables.get(name);
|
|
3808
3818
|
if (table === undefined) {
|
|
3809
|
-
throw new ClientSyncError('sync.unknown_table', `unknown
|
|
3819
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
3810
3820
|
}
|
|
3811
3821
|
return table;
|
|
3812
3822
|
}
|
|
3813
3823
|
#requireStarted() {
|
|
3814
3824
|
if (!this.#started) {
|
|
3815
|
-
throw invalidRequest('SyncClient.start()
|
|
3825
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
3816
3826
|
}
|
|
3817
3827
|
}
|
|
3818
3828
|
#requireActive() {
|
|
3819
3829
|
this.#requireStarted();
|
|
3820
3830
|
if (this.#securityLifecycle === 'preflight') {
|
|
3821
|
-
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, '
|
|
3831
|
+
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'security preflight must complete before accessing protected data; call activateSecurity');
|
|
3822
3832
|
}
|
|
3823
3833
|
}
|
|
3824
3834
|
}
|
|
3835
|
+
function definedObject(record) {
|
|
3836
|
+
for (const key of Object.keys(record)) {
|
|
3837
|
+
if (record[key] === undefined)
|
|
3838
|
+
delete record[key];
|
|
3839
|
+
}
|
|
3840
|
+
return record;
|
|
3841
|
+
}
|
|
3825
3842
|
function requireRowId(value) {
|
|
3826
3843
|
if (typeof value !== 'string' || !value.length)
|
|
3827
3844
|
throw invalidRequest('a mutation requires a non-empty string primary key');
|