@syncular/client 0.30.11 → 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 +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.js +75 -59
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +115 -76
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
package/README.md
CHANGED
|
@@ -18,6 +18,13 @@ Ordinary commits use wire version 1 until the caller supplies an acquired
|
|
|
18
18
|
partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
|
|
19
19
|
validation.
|
|
20
20
|
|
|
21
|
+
Applied and cached acknowledgements drain the send queue while protected local
|
|
22
|
+
intent stays visible until the same row's server image arrives at that commit
|
|
23
|
+
sequence or later. Empty pulls and restart preserve it; later edits stack above
|
|
24
|
+
it. This behavior is automatic, independent of `retainFailedCommits`. The core
|
|
25
|
+
requests a following pull without relying on an origin realtime notification.
|
|
26
|
+
See [optimistic writes](https://syncular.dev/concepts-conflicts/#the-optimistic-outbox).
|
|
27
|
+
|
|
21
28
|
## Client-local FTS5 projections
|
|
22
29
|
|
|
23
30
|
Generated schemas may attach `ftsIndexes` to a synced table. The
|
package/dist/apply.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
|
|
|
23
23
|
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
24
24
|
* never half-applies.
|
|
25
25
|
*/
|
|
26
|
-
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?:
|
|
26
|
+
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
|
|
27
27
|
/**
|
|
28
28
|
* §5.2: the segment's column table must match the generated schema for
|
|
29
29
|
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
|
@@ -37,6 +37,10 @@ export declare function validateSegmentColumns(schema: CompiledClientSchema, tab
|
|
|
37
37
|
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
38
38
|
* precision or nothing, never clear-the-table.
|
|
39
39
|
*/
|
|
40
|
+
export declare function localScopePredicate(table: CompiledClientTable, effective: ScopeMap): {
|
|
41
|
+
sql: string;
|
|
42
|
+
params: string[];
|
|
43
|
+
} | undefined;
|
|
40
44
|
export declare function deleteScopedRows(db: ClientDatabase, table: CompiledClientTable, effective: ScopeMap): void;
|
|
41
45
|
/**
|
|
42
46
|
* §4.8 window eviction: delete rows matching a departing unit's effective
|
package/dist/apply.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
1
2
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
2
3
|
/**
|
|
3
4
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
@@ -6,7 +7,7 @@ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
|
6
7
|
*/
|
|
7
8
|
import { decodeRow, } from '@syncular/core';
|
|
8
9
|
import { ClientSyncError, invalidRequest } from './errors.js';
|
|
9
|
-
import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
|
|
10
|
+
import { quoteIdent, matchesLocalScopes, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
|
|
10
11
|
function upsertSql(table) {
|
|
11
12
|
const names = [
|
|
12
13
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
@@ -75,8 +76,14 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
78
|
transaction(() => {
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const rowKeys = frame.tables.some((name) => schema.tables.get(name)?.indexes.some((index) => index.unique))
|
|
80
|
+
? undefined
|
|
81
|
+
: frame.changes.map((change) => JSON.stringify([frame.tables[change.tableIndex], change.rowId]));
|
|
82
|
+
restoreFailedBases(db, schema, false, rowKeys);
|
|
83
|
+
for (const [index, change] of resolved.entries()) {
|
|
84
|
+
const rowId = frame.changes[index].rowId;
|
|
85
|
+
db.exec("UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)", [frame.commitSeq, change.table.name, rowId]);
|
|
86
|
+
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
|
|
80
87
|
if (change.op === 'delete') {
|
|
81
88
|
deleteLocalRow(db, change.table, change.rowId);
|
|
82
89
|
}
|
|
@@ -84,6 +91,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
84
91
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
85
92
|
}
|
|
86
93
|
}
|
|
94
|
+
return rowKeys;
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
97
|
/**
|
|
@@ -123,26 +131,31 @@ export function validateSegmentColumns(schema, table, segment) {
|
|
|
123
131
|
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
124
132
|
* precision or nothing, never clear-the-table.
|
|
125
133
|
*/
|
|
126
|
-
export function
|
|
134
|
+
export function localScopePredicate(table, effective) {
|
|
127
135
|
const entries = Object.entries(effective);
|
|
128
|
-
if (entries.length
|
|
136
|
+
if (!entries.length || entries.some(([, values]) => !values.length))
|
|
129
137
|
return;
|
|
130
|
-
const clauses = [];
|
|
131
138
|
const params = [];
|
|
132
|
-
|
|
139
|
+
const sql = entries
|
|
140
|
+
.map(([variable, values]) => {
|
|
133
141
|
const column = table.scopeColumnByVariable.get(variable);
|
|
134
|
-
if (column === undefined)
|
|
135
|
-
throw new ClientSyncError('sync.scope_revoked',
|
|
136
|
-
}
|
|
137
|
-
if (values.length === 0)
|
|
138
|
-
return;
|
|
139
|
-
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
142
|
+
if (column === undefined)
|
|
143
|
+
throw new ClientSyncError('sync.scope_revoked', 'table has no local scope-column mapping');
|
|
140
144
|
params.push(...values);
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
|
|
146
|
+
})
|
|
147
|
+
.join(' AND ');
|
|
148
|
+
return { sql, params };
|
|
149
|
+
}
|
|
150
|
+
export function deleteScopedRows(db, table, effective) {
|
|
151
|
+
const scope = localScopePredicate(table, effective);
|
|
152
|
+
if (!scope)
|
|
153
|
+
return;
|
|
154
|
+
const { sql, params } = scope;
|
|
155
|
+
for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
|
|
143
156
|
retainedBaseWrite(db, table, String(row.id));
|
|
144
157
|
}
|
|
145
|
-
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${
|
|
158
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
146
159
|
}
|
|
147
160
|
/**
|
|
148
161
|
* §4.8 window eviction: delete rows matching a departing unit's effective
|
|
@@ -155,21 +168,10 @@ export function deleteScopedRows(db, table, effective) {
|
|
|
155
168
|
* cache), which is automatic since the version column is per-row.
|
|
156
169
|
*/
|
|
157
170
|
export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
171
|
+
const scope = localScopePredicate(table, effective);
|
|
172
|
+
if (!scope)
|
|
160
173
|
return { remaining: false, deferred: false };
|
|
161
|
-
const
|
|
162
|
-
const params = [];
|
|
163
|
-
for (const [variable, values] of entries) {
|
|
164
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
165
|
-
if (column === undefined) {
|
|
166
|
-
throw new ClientSyncError('sync.scope_revoked', `table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`);
|
|
167
|
-
}
|
|
168
|
-
if (values.length === 0)
|
|
169
|
-
return { remaining: false, deferred: false };
|
|
170
|
-
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
171
|
-
params.push(...values);
|
|
172
|
-
}
|
|
174
|
+
const { sql, params } = scope;
|
|
173
175
|
const pk = quoteIdent(table.primaryKey);
|
|
174
176
|
let pinnedClause = '';
|
|
175
177
|
if (pinnedRowIds.size > 0) {
|
|
@@ -178,10 +180,16 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
|
|
|
178
180
|
params.push(...ids);
|
|
179
181
|
}
|
|
180
182
|
const target = quoteIdent(table.name);
|
|
181
|
-
db.
|
|
182
|
-
|
|
183
|
+
for (const row of db.query(`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`, [table.name])) {
|
|
184
|
+
if (!pinnedRowIds.has(row.id) &&
|
|
185
|
+
[row.intent, row.base].some((json) => json !== null &&
|
|
186
|
+
matchesLocalScopes(table, effective, JSON.parse(json))))
|
|
187
|
+
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`, [table.name, row.id]);
|
|
188
|
+
}
|
|
189
|
+
db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`, params);
|
|
190
|
+
const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`, params).length > 0;
|
|
183
191
|
const deferred = remaining ||
|
|
184
|
-
db.query(`SELECT 1 FROM ${target} WHERE ${
|
|
192
|
+
db.query(`SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
|
|
185
193
|
return { remaining, deferred };
|
|
186
194
|
}
|
|
187
195
|
const IMAGE_ALIAS = 'syncular_image';
|
|
@@ -199,7 +207,7 @@ function imageInvalid(detail) {
|
|
|
199
207
|
export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
|
|
200
208
|
const withImage = db.withSqliteImage?.bind(db);
|
|
201
209
|
if (withImage === undefined) {
|
|
202
|
-
throw invalidRequest('
|
|
210
|
+
throw invalidRequest('database cannot import images; disable accept bit 2 (§4.2)');
|
|
203
211
|
}
|
|
204
212
|
if (descriptor.table !== table.name) {
|
|
205
213
|
imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
|
|
@@ -339,12 +347,12 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
339
347
|
if (!clearThisBlock && rows.length === 0)
|
|
340
348
|
continue;
|
|
341
349
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
342
|
-
restoreFailedBases(db, schema);
|
|
350
|
+
const protectedRows = restoreFailedBases(db, schema);
|
|
343
351
|
if (clearThisBlock) {
|
|
344
352
|
deleteScopedRows(db, table, options.effective);
|
|
345
353
|
}
|
|
346
354
|
for (const row of rows) {
|
|
347
|
-
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
355
|
+
upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
|
|
348
356
|
applied += 1;
|
|
349
357
|
if (applied % 1024 === 0)
|
|
350
358
|
options.onProgress?.(applied);
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
1
2
|
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
2
3
|
import { ProgressEmitter, } from './progress.js';
|
|
3
4
|
/**
|
|
@@ -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,7 +26,7 @@ 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 { 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';
|
|
29
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. */
|
|
@@ -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) {
|
|
@@ -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') {
|
|
@@ -396,6 +398,7 @@ export class SyncClient {
|
|
|
396
398
|
writePreviousVersionAudit(this.#db, buildPreviousVersionAudit(this.#schema, listOutbox(this.#db), previousVersion, this.#schema.version, this.#now()));
|
|
397
399
|
}
|
|
398
400
|
pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema, true);
|
|
401
|
+
this.#discardAcknowledgedRows();
|
|
399
402
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
400
403
|
resetSubscriptionsForBump(this.#db);
|
|
401
404
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
@@ -477,6 +480,7 @@ export class SyncClient {
|
|
|
477
480
|
this.#sweepPreviousVersionContainer();
|
|
478
481
|
this.#applyBatch((batch) => {
|
|
479
482
|
this.#db.transaction(() => {
|
|
483
|
+
this.#discardAcknowledgedRows();
|
|
480
484
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
481
485
|
resetSubscriptionsForBump(this.#db);
|
|
482
486
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
@@ -1179,7 +1183,7 @@ export class SyncClient {
|
|
|
1179
1183
|
try {
|
|
1180
1184
|
result = fn(batch);
|
|
1181
1185
|
if (this.#db
|
|
1182
|
-
.query(
|
|
1186
|
+
.query(`SELECT DISTINCT tbl FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL`)
|
|
1183
1187
|
.some((row) => batch.hasTable(String(row.tbl))))
|
|
1184
1188
|
batch.outcomes();
|
|
1185
1189
|
if (batch.touched) {
|
|
@@ -1454,7 +1458,7 @@ export class SyncClient {
|
|
|
1454
1458
|
const priorRejections = this.#rejections;
|
|
1455
1459
|
return this.#applyBatch((batch) => {
|
|
1456
1460
|
restoreFailedBases(this.#db, this.#schema);
|
|
1457
|
-
this.#db.exec(
|
|
1461
|
+
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [
|
|
1458
1462
|
input.clientCommitId,
|
|
1459
1463
|
]);
|
|
1460
1464
|
const resolved = persistCommitOutcomeResolution(this.#db, input, this.#now());
|
|
@@ -1569,7 +1573,7 @@ export class SyncClient {
|
|
|
1569
1573
|
canonicalScopeJson(input.scopes) &&
|
|
1570
1574
|
existing.params === input.params;
|
|
1571
1575
|
if (!sameIntent) {
|
|
1572
|
-
throw new ClientSyncError('client.subscription_intent_mismatch', '
|
|
1576
|
+
throw new ClientSyncError('client.subscription_intent_mismatch', 'subscription id already names different table, scopes, or params');
|
|
1573
1577
|
}
|
|
1574
1578
|
return;
|
|
1575
1579
|
}
|
|
@@ -1616,7 +1620,7 @@ export class SyncClient {
|
|
|
1616
1620
|
this.#requireActive();
|
|
1617
1621
|
const table = this.#table(base.table);
|
|
1618
1622
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
1619
|
-
throw invalidRequest(
|
|
1623
|
+
throw invalidRequest(`${base.table}: unknown scope variable ${base.variable} (§4.8)`);
|
|
1620
1624
|
}
|
|
1621
1625
|
// Serialize the whole window edit: it spans an `await deriveSubId` between
|
|
1622
1626
|
// db transactions, so without the chain a delta apply (or a concurrent
|
|
@@ -1983,16 +1987,7 @@ export class SyncClient {
|
|
|
1983
1987
|
return this.#applyBatch((batch) => {
|
|
1984
1988
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1985
1989
|
restoreFailedBases(this.#db, this.#schema);
|
|
1986
|
-
|
|
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);
|
|
1992
|
-
for (const operation of commit.operations)
|
|
1993
|
-
batch.table(operation.table);
|
|
1994
|
-
batch.outcomes();
|
|
1995
|
-
}
|
|
1990
|
+
this.#dropRetainedRows(batch, (table, values) => (targetsByTable.get(table) ?? []).some((target) => target.selectors.every((selector) => selector.values.includes(String(values[selector.column])))));
|
|
1996
1991
|
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
1997
1992
|
// a commit is doomed when any of its ops touches a base-matching
|
|
1998
1993
|
// row. Base values only become visible here as rollbacks restore
|
|
@@ -2128,6 +2123,7 @@ export class SyncClient {
|
|
|
2128
2123
|
this.#upgrading = true;
|
|
2129
2124
|
this.#needsPull = true;
|
|
2130
2125
|
batch.status();
|
|
2126
|
+
this.#discardAcknowledgedRows();
|
|
2131
2127
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
2132
2128
|
resetSubscriptionsForBump(this.#db);
|
|
2133
2129
|
for (const table of this.#schema.tables.values()) {
|
|
@@ -2972,6 +2968,7 @@ export class SyncClient {
|
|
|
2972
2968
|
index += 1;
|
|
2973
2969
|
}
|
|
2974
2970
|
}
|
|
2971
|
+
const previousNeedsPull = this.#needsPull;
|
|
2975
2972
|
const conflictCount = this.#conflicts.length;
|
|
2976
2973
|
const rejectionCount = this.#rejections.length;
|
|
2977
2974
|
let outboxCount = countOutbox(this.#db);
|
|
@@ -2988,9 +2985,10 @@ export class SyncClient {
|
|
|
2988
2985
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2989
2986
|
}
|
|
2990
2987
|
}, () => this.#statusSnapshot(outboxCount), (cause) => {
|
|
2988
|
+
this.#needsPull = previousNeedsPull;
|
|
2991
2989
|
this.#conflicts.length = conflictCount;
|
|
2992
2990
|
this.#rejections.length = rejectionCount;
|
|
2993
|
-
const error = new ClientSyncError('client.outcome_persistence_failed', '
|
|
2991
|
+
const error = new ClientSyncError('client.outcome_persistence_failed', 'outcome persistence failed');
|
|
2994
2992
|
error.cause = cause;
|
|
2995
2993
|
throw error;
|
|
2996
2994
|
});
|
|
@@ -3201,6 +3199,10 @@ export class SyncClient {
|
|
|
3201
3199
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3202
3200
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3203
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);
|
|
3204
3206
|
recordCommitOutcome(this.#db, {
|
|
3205
3207
|
clientCommitId: frame.clientCommitId,
|
|
3206
3208
|
status: frame.status,
|
|
@@ -3327,10 +3329,7 @@ export class SyncClient {
|
|
|
3327
3329
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3328
3330
|
this.#recordCommitChanges(batch, frame);
|
|
3329
3331
|
const result = fn();
|
|
3330
|
-
|
|
3331
|
-
this.#db.query('SELECT 1 FROM _syncular_failed_rows LIMIT 1')
|
|
3332
|
-
.length > 0)
|
|
3333
|
-
this.#replayOutbox();
|
|
3332
|
+
this.#replayOutbox(result);
|
|
3334
3333
|
return result;
|
|
3335
3334
|
}));
|
|
3336
3335
|
summary.commitsApplied += 1;
|
|
@@ -3375,19 +3374,9 @@ export class SyncClient {
|
|
|
3375
3374
|
}
|
|
3376
3375
|
/** Whether a fresh-bootstrap clear would remove at least one local row. */
|
|
3377
3376
|
#scopedRowsExist(table, effective) {
|
|
3378
|
-
const
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
const clauses = [];
|
|
3382
|
-
const params = [];
|
|
3383
|
-
for (const [variable, values] of entries) {
|
|
3384
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
3385
|
-
if (column === undefined || values.length === 0)
|
|
3386
|
-
return false;
|
|
3387
|
-
clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
|
|
3388
|
-
params.push(...values);
|
|
3389
|
-
}
|
|
3390
|
-
return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)} 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);
|
|
3391
3380
|
}
|
|
3392
3381
|
/**
|
|
3393
3382
|
* Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
|
|
@@ -3461,7 +3450,7 @@ export class SyncClient {
|
|
|
3461
3450
|
if (frame.urlExpiresAtMs !== undefined &&
|
|
3462
3451
|
frame.urlExpiresAtMs <= this.#now()) {
|
|
3463
3452
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
3464
|
-
throw new ClientSyncError('sync.segment_expired', `
|
|
3453
|
+
throw new ClientSyncError('sync.segment_expired', `segment download grant expired; pull again (§5.4)`, true);
|
|
3465
3454
|
}
|
|
3466
3455
|
bytes = await fetchUrl(frame.url, onProgress);
|
|
3467
3456
|
}
|
|
@@ -3504,6 +3493,25 @@ export class SyncClient {
|
|
|
3504
3493
|
effectiveScopes: start.effectiveScopes,
|
|
3505
3494
|
status: 'active',
|
|
3506
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
|
+
}
|
|
3507
3515
|
if (completed && registered !== undefined) {
|
|
3508
3516
|
// A zero-row bootstrap is a window-domain transition, not a fake
|
|
3509
3517
|
// row/table change (SPEC §4.8 / §7.5).
|
|
@@ -3545,17 +3553,8 @@ export class SyncClient {
|
|
|
3545
3553
|
Object.keys(lastEffective).length > 0) {
|
|
3546
3554
|
try {
|
|
3547
3555
|
restoreFailedBases(this.#db, this.#schema);
|
|
3548
|
-
|
|
3549
|
-
|
|
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);
|
|
3555
|
-
for (const operation of commit.operations)
|
|
3556
|
-
batch.table(operation.table);
|
|
3557
|
-
batch.outcomes();
|
|
3558
|
-
}
|
|
3556
|
+
this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3557
|
+
matchesLocalScopes(table, lastEffective, values));
|
|
3559
3558
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3560
3559
|
batch.scopeMap(table, lastEffective);
|
|
3561
3560
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3775,19 +3774,36 @@ export class SyncClient {
|
|
|
3775
3774
|
}
|
|
3776
3775
|
}
|
|
3777
3776
|
/** Re-apply every pending outbox commit on top of server state (§7.1). */
|
|
3778
|
-
#
|
|
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);
|
|
3779
3793
|
const pending = [
|
|
3780
|
-
...failedOverlayCommits(this.#db),
|
|
3781
|
-
...listOutbox(this.#db),
|
|
3794
|
+
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3795
|
+
...listOutbox(this.#db, rowKeys),
|
|
3782
3796
|
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3783
|
-
if (pending.length === 0)
|
|
3797
|
+
if (pending.length === 0 && acknowledged.length === 0)
|
|
3784
3798
|
return;
|
|
3785
3799
|
this.#applyBatch((batch) => {
|
|
3786
3800
|
this.#db.transaction(() => {
|
|
3787
|
-
restoreFailedBases(this.#db, this.#schema);
|
|
3788
|
-
restoreFailedBases(this.#db, this.#schema, true);
|
|
3789
|
-
for (const commit of pending)
|
|
3790
|
-
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);
|
|
3791
3807
|
});
|
|
3792
3808
|
});
|
|
3793
3809
|
}
|
|
@@ -3800,19 +3816,19 @@ export class SyncClient {
|
|
|
3800
3816
|
#table(name) {
|
|
3801
3817
|
const table = this.#schema.tables.get(name);
|
|
3802
3818
|
if (table === undefined) {
|
|
3803
|
-
throw new ClientSyncError('sync.unknown_table', `unknown
|
|
3819
|
+
throw new ClientSyncError('sync.unknown_table', `unknown table ${name}`);
|
|
3804
3820
|
}
|
|
3805
3821
|
return table;
|
|
3806
3822
|
}
|
|
3807
3823
|
#requireStarted() {
|
|
3808
3824
|
if (!this.#started) {
|
|
3809
|
-
throw invalidRequest('SyncClient.start()
|
|
3825
|
+
throw invalidRequest('call SyncClient.start() first');
|
|
3810
3826
|
}
|
|
3811
3827
|
}
|
|
3812
3828
|
#requireActive() {
|
|
3813
3829
|
this.#requireStarted();
|
|
3814
3830
|
if (this.#securityLifecycle === 'preflight') {
|
|
3815
|
-
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');
|
|
3816
3832
|
}
|
|
3817
3833
|
}
|
|
3818
3834
|
}
|
package/dist/failed-overlay.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const RETAINED_ROWS = "_syncular_failed_rows";
|
|
1
2
|
import type { ClientDatabase } from './database.js';
|
|
2
3
|
import type { CompiledClientSchema, CompiledClientTable, JsonRowValue } from './schema.js';
|
|
3
4
|
import type { RowValue } from '@syncular/core';
|
|
@@ -6,9 +7,9 @@ import type { OutboxCommit, OutboxBeforeImage } from './outbox.js';
|
|
|
6
7
|
/** Protected failure state. The server base advances independently of intent. */
|
|
7
8
|
export declare function ensureFailedOverlaySchema(db: ClientDatabase): void;
|
|
8
9
|
export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledClientTable, rowId: string, values?: Readonly<Record<string, JsonRowValue>>, version?: number): void;
|
|
9
|
-
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[]): void;
|
|
10
|
+
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
|
|
10
11
|
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
11
12
|
export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
|
|
12
|
-
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean):
|
|
13
|
-
export declare function failedOverlayCommits(db: ClientDatabase): OutboxCommit[];
|
|
13
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, absentIntent?: boolean, rowKeys?: readonly string[]): boolean;
|
|
14
|
+
export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, rowKeys?: readonly string[]): OutboxCommit[];
|
|
14
15
|
export declare function dropFailedRows(db: ClientDatabase, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|