@syncular/client 0.30.13 → 0.30.15
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 +10 -0
- package/dist/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/authority.d.ts +43 -0
- package/dist/authority.js +257 -0
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.d.ts +4 -0
- package/dist/client.js +69 -57
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- package/dist/worker-entry.js +7 -0
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +8 -0
- package/dist/worker-protocol.d.ts +2 -0
- package/package.json +3 -3
- package/src/apply.ts +106 -37
- package/src/authority.ts +414 -0
- package/src/blob.ts +4 -3
- package/src/client.ts +101 -75
- package/src/failed-overlay.ts +96 -78
- package/src/index.ts +6 -0
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
- package/src/worker-entry.ts +10 -0
- package/src/worker-host.ts +14 -0
- package/src/worker-protocol.ts +2 -0
package/dist/client.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
import { hasUniqueIndex } from './schema.js';
|
|
3
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
4
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
5
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
6
|
import { dropFailedRows, failedOverlayCommits, retainFailedRows, restoreFailedBases, uniqueConflicts, } from './failed-overlay.js';
|
|
3
7
|
import { ProgressEmitter, } from './progress.js';
|
|
@@ -126,6 +130,7 @@ function realtimeErrorCode(error) {
|
|
|
126
130
|
}
|
|
127
131
|
export class SyncClient {
|
|
128
132
|
#config;
|
|
133
|
+
#authorityReads;
|
|
129
134
|
#db;
|
|
130
135
|
#schema;
|
|
131
136
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
@@ -233,6 +238,8 @@ export class SyncClient {
|
|
|
233
238
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
234
239
|
this.#db = config.database;
|
|
235
240
|
this.#schema = compileClientSchema(config.schema);
|
|
241
|
+
this.#authorityReads = config.authorityReads;
|
|
242
|
+
this.#authorityReads?.validate(this.#schema);
|
|
236
243
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
237
244
|
if (realtimePolicy !== 'required' &&
|
|
238
245
|
realtimePolicy !== 'optional' &&
|
|
@@ -478,6 +485,13 @@ export class SyncClient {
|
|
|
478
485
|
}
|
|
479
486
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
480
487
|
#runLogEpochReset(logEpoch) {
|
|
488
|
+
if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
|
|
489
|
+
this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
|
|
490
|
+
this.#setSyncNeeded(true);
|
|
491
|
+
this.#emitSyncNeeded('startup');
|
|
492
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
481
495
|
const subscriptions = loadSubscriptions(this.#db);
|
|
482
496
|
const pending = listOutbox(this.#db);
|
|
483
497
|
this.#setUpgrading(true);
|
|
@@ -530,6 +544,7 @@ export class SyncClient {
|
|
|
530
544
|
});
|
|
531
545
|
}
|
|
532
546
|
async close() {
|
|
547
|
+
this.#started = false;
|
|
533
548
|
this.#emitSyncIntent({ kind: 'none' });
|
|
534
549
|
this.#devtoolsUnregister?.();
|
|
535
550
|
this.#devtoolsUnregister = undefined;
|
|
@@ -537,7 +552,6 @@ export class SyncClient {
|
|
|
537
552
|
this.#abortPendingRound('client closed mid-round');
|
|
538
553
|
await this.#lease?.release();
|
|
539
554
|
this.#lease = undefined;
|
|
540
|
-
this.#started = false;
|
|
541
555
|
this.#progress.clear();
|
|
542
556
|
this.#syncNeededListeners.clear();
|
|
543
557
|
this.#syncIntentListeners.clear();
|
|
@@ -685,6 +699,13 @@ export class SyncClient {
|
|
|
685
699
|
this.#requireStarted();
|
|
686
700
|
return getLocalRevision(this.#db);
|
|
687
701
|
}
|
|
702
|
+
/** Read the creation-time plain authority policy, including preflight. */
|
|
703
|
+
authoritySnapshot() {
|
|
704
|
+
this.#requireStarted();
|
|
705
|
+
if (!this.#authorityReads || arguments.length)
|
|
706
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authority read denied');
|
|
707
|
+
return this.#authorityReads.snapshot(this.#db, this.#schema);
|
|
708
|
+
}
|
|
688
709
|
/**
|
|
689
710
|
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
690
711
|
* Reactive integrations use this instead of composing `query()` and
|
|
@@ -703,24 +724,13 @@ export class SyncClient {
|
|
|
703
724
|
const missing = [];
|
|
704
725
|
for (const requested of spec.coverage ?? []) {
|
|
705
726
|
const baseKey = windowBaseKey(requested.base);
|
|
706
|
-
const
|
|
707
|
-
entry.unit,
|
|
708
|
-
entry.subId,
|
|
709
|
-
]));
|
|
727
|
+
const state = this.windowState(requested.base);
|
|
710
728
|
for (const unit of new Set(requested.units)) {
|
|
711
|
-
const subId = live.get(unit);
|
|
712
729
|
const ref = { baseKey, unit };
|
|
713
|
-
if (
|
|
730
|
+
if (!state.units.includes(unit))
|
|
714
731
|
missing.push(ref);
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
const sub = getSubscription(this.#db, subId);
|
|
718
|
-
if (sub === undefined ||
|
|
719
|
-
sub.status !== 'active' ||
|
|
720
|
-
sub.cursor < 0 ||
|
|
721
|
-
sub.bootstrapState !== undefined) {
|
|
732
|
+
else if (state.pending.includes(unit))
|
|
722
733
|
pending.push(ref);
|
|
723
|
-
}
|
|
724
734
|
}
|
|
725
735
|
}
|
|
726
736
|
return {
|
|
@@ -1033,7 +1043,7 @@ export class SyncClient {
|
|
|
1033
1043
|
item.id.length === 0 ||
|
|
1034
1044
|
typeof item.table !== 'string' ||
|
|
1035
1045
|
item.table.length === 0) {
|
|
1036
|
-
throw invalidRequest('diagnosticsSnapshot
|
|
1046
|
+
throw invalidRequest('diagnosticsSnapshot needs subscription id and table');
|
|
1037
1047
|
}
|
|
1038
1048
|
const registeredSubscription = subscriptions.get(item.id);
|
|
1039
1049
|
if (registeredSubscription !== undefined &&
|
|
@@ -1128,8 +1138,8 @@ export class SyncClient {
|
|
|
1128
1138
|
try {
|
|
1129
1139
|
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
1130
1140
|
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
1131
|
-
const outboxBytes = Number(this.#db.query(
|
|
1132
|
-
const outcome = this.#db.query(
|
|
1141
|
+
const outboxBytes = Number(this.#db.query(`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`)[0]?.bytes ?? 0);
|
|
1142
|
+
const outcome = this.#db.query(`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`)[0];
|
|
1133
1143
|
const blobBytes = this.#hasBlobs
|
|
1134
1144
|
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length),0)AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
1135
1145
|
: 0;
|
|
@@ -1925,7 +1935,7 @@ export class SyncClient {
|
|
|
1925
1935
|
catch {
|
|
1926
1936
|
continue;
|
|
1927
1937
|
}
|
|
1928
|
-
this.#db.exec(
|
|
1938
|
+
this.#db.exec(`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`, [clientCommitId, blobId, blobId]);
|
|
1929
1939
|
}
|
|
1930
1940
|
}
|
|
1931
1941
|
try {
|
|
@@ -2143,7 +2153,7 @@ export class SyncClient {
|
|
|
2143
2153
|
};
|
|
2144
2154
|
}
|
|
2145
2155
|
if (this.#schemaFloor !== undefined) {
|
|
2146
|
-
throw invalidRequest('local rebootstrap
|
|
2156
|
+
throw invalidRequest('local rebootstrap needs a schema-floor update');
|
|
2147
2157
|
}
|
|
2148
2158
|
const pending = listOutbox(this.#db);
|
|
2149
2159
|
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
@@ -2250,7 +2260,7 @@ export class SyncClient {
|
|
|
2250
2260
|
async #encodeOutboxForPush() {
|
|
2251
2261
|
// Pin before the first encryption await: mutations can append while a
|
|
2252
2262
|
// round is encoding, and belong to the next request.
|
|
2253
|
-
const bounds = this.#db.query(
|
|
2263
|
+
const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
|
|
2254
2264
|
const pendingCount = bounds.count;
|
|
2255
2265
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2256
2266
|
const pushFrames = [];
|
|
@@ -3140,14 +3150,7 @@ export class SyncClient {
|
|
|
3140
3150
|
}
|
|
3141
3151
|
}
|
|
3142
3152
|
finally {
|
|
3143
|
-
|
|
3144
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
3145
|
-
// the still-pending commits on top of the freshly applied server state.
|
|
3146
|
-
this.#replayOutbox();
|
|
3147
|
-
}
|
|
3148
|
-
finally {
|
|
3149
|
-
this.#endDiagnosticsDeferral();
|
|
3150
|
-
}
|
|
3153
|
+
this.#endDiagnosticsDeferral();
|
|
3151
3154
|
}
|
|
3152
3155
|
if (errorFrame !== undefined)
|
|
3153
3156
|
throw errorFrame;
|
|
@@ -3276,13 +3279,16 @@ export class SyncClient {
|
|
|
3276
3279
|
if (this.#retainFailedCommits)
|
|
3277
3280
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3278
3281
|
this.#rollbackFailedCommit(commit, batch);
|
|
3282
|
+
this.#replayOutbox(commit.operations.map((operation) => JSON.stringify(hasUniqueIndex(this.#table(operation.table))
|
|
3283
|
+
? [operation.table]
|
|
3284
|
+
: [operation.table, operation.rowId])));
|
|
3279
3285
|
});
|
|
3280
3286
|
batch.status();
|
|
3281
3287
|
summary.rejected.push(frame.clientCommitId);
|
|
3282
3288
|
return true;
|
|
3283
3289
|
}
|
|
3284
3290
|
#outboxCommitExists(clientCommitId) {
|
|
3285
|
-
return (this.#db.query(
|
|
3291
|
+
return (this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [clientCommitId]).length > 0);
|
|
3286
3292
|
}
|
|
3287
3293
|
#decodeServerRow(tableName, payload) {
|
|
3288
3294
|
if (tableName === undefined)
|
|
@@ -3320,7 +3326,7 @@ export class SyncClient {
|
|
|
3320
3326
|
await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
|
|
3321
3327
|
this.#recordCommitChanges(batch, frame);
|
|
3322
3328
|
const result = fn();
|
|
3323
|
-
this.#replayOutbox(result);
|
|
3329
|
+
this.#replayOutbox(result, false);
|
|
3324
3330
|
return result;
|
|
3325
3331
|
}));
|
|
3326
3332
|
summary.commitsApplied += 1;
|
|
@@ -3381,7 +3387,7 @@ export class SyncClient {
|
|
|
3381
3387
|
(clearFirst && this.#scopedRowsExist(table, effective)))
|
|
3382
3388
|
batch.table(table.name);
|
|
3383
3389
|
const result = fn();
|
|
3384
|
-
this.#replayOutbox();
|
|
3390
|
+
this.#replayOutbox(result, false);
|
|
3385
3391
|
return result;
|
|
3386
3392
|
}),
|
|
3387
3393
|
};
|
|
@@ -3513,11 +3519,12 @@ export class SyncClient {
|
|
|
3513
3519
|
return matchesLocalScopes(table, effective, values);
|
|
3514
3520
|
}));
|
|
3515
3521
|
if (covered.length) {
|
|
3516
|
-
|
|
3522
|
+
const scope = covered.map((row) => JSON.stringify([sub.table, String(row.id)]));
|
|
3523
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3517
3524
|
for (const row of covered)
|
|
3518
3525
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [sub.table, String(row.id), nextCursor]);
|
|
3519
3526
|
batch.table(sub.table);
|
|
3520
|
-
this.#replayOutbox();
|
|
3527
|
+
this.#replayOutbox(scope, false);
|
|
3521
3528
|
}
|
|
3522
3529
|
}
|
|
3523
3530
|
if (completed && registered !== undefined) {
|
|
@@ -3560,9 +3567,11 @@ export class SyncClient {
|
|
|
3560
3567
|
if (lastEffective !== undefined &&
|
|
3561
3568
|
Object.keys(lastEffective).length > 0) {
|
|
3562
3569
|
try {
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3570
|
+
const scope = [
|
|
3571
|
+
JSON.stringify([table.name]),
|
|
3572
|
+
...this.#dropRetainedRows(batch, (name, values) => name === table.name &&
|
|
3573
|
+
matchesLocalScopes(table, lastEffective, values)),
|
|
3574
|
+
];
|
|
3566
3575
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
3567
3576
|
batch.scopeMap(table, lastEffective);
|
|
3568
3577
|
const pendingById = new Map(listOutbox(this.#db).map((commit) => [
|
|
@@ -3600,6 +3609,7 @@ export class SyncClient {
|
|
|
3600
3609
|
batch.rejections();
|
|
3601
3610
|
batch.outcomes();
|
|
3602
3611
|
}
|
|
3612
|
+
this.#replayOutbox(scope);
|
|
3603
3613
|
this.#deleteUnreferencedBlobs();
|
|
3604
3614
|
}
|
|
3605
3615
|
catch (error) {
|
|
@@ -3772,7 +3782,7 @@ export class SyncClient {
|
|
|
3772
3782
|
? OPTIMISTIC_VERSION
|
|
3773
3783
|
: local[SYNC_VERSION_COLUMN];
|
|
3774
3784
|
if (replay &&
|
|
3775
|
-
table
|
|
3785
|
+
hasUniqueIndex(table) &&
|
|
3776
3786
|
uniqueConflicts(this.#db, table, Object.fromEntries(table.columns.map((column, index) => [
|
|
3777
3787
|
column.name,
|
|
3778
3788
|
values[index] ?? null,
|
|
@@ -3786,33 +3796,35 @@ export class SyncClient {
|
|
|
3786
3796
|
this.#db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL`);
|
|
3787
3797
|
}
|
|
3788
3798
|
#dropRetainedRows(batch, matches) {
|
|
3789
|
-
|
|
3799
|
+
const scope = [];
|
|
3800
|
+
for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
|
|
3790
3801
|
batch.conflicts();
|
|
3791
3802
|
batch.rejections();
|
|
3792
3803
|
batch.outcomes();
|
|
3793
3804
|
this.#conflicts = this.#conflicts.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3794
3805
|
this.#rejections = this.#rejections.filter((record) => record.clientCommitId !== commit.clientCommitId);
|
|
3795
|
-
for (const op of commit.operations)
|
|
3806
|
+
for (const op of commit.operations) {
|
|
3796
3807
|
batch.table(op.table);
|
|
3808
|
+
scope.push(JSON.stringify([op.table, op.rowId]));
|
|
3809
|
+
}
|
|
3797
3810
|
}
|
|
3811
|
+
return scope;
|
|
3798
3812
|
}
|
|
3799
|
-
#replayOutbox(
|
|
3800
|
-
|
|
3801
|
-
const pending = [
|
|
3802
|
-
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
3803
|
-
...listOutbox(this.#db, rowKeys),
|
|
3804
|
-
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3805
|
-
if (pending.length === 0 && acknowledged.length === 0)
|
|
3813
|
+
#replayOutbox(scope, restoreBases = true) {
|
|
3814
|
+
if (scope?.length === 0)
|
|
3806
3815
|
return;
|
|
3807
3816
|
this.#applyBatch((batch) => {
|
|
3808
|
-
|
|
3809
|
-
restoreFailedBases(this.#db, this.#schema,
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3817
|
+
if (restoreBases)
|
|
3818
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3819
|
+
const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
|
|
3820
|
+
const pending = [
|
|
3821
|
+
...failedOverlayCommits(this.#db, false, scope, this.#schema),
|
|
3822
|
+
...listOutbox(this.#db, scope),
|
|
3823
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
3824
|
+
for (const commit of [...acknowledged, ...pending])
|
|
3825
|
+
this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
|
|
3826
|
+
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
3827
|
+
scope.includes(JSON.stringify([op.table]))), batch, true);
|
|
3816
3828
|
});
|
|
3817
3829
|
}
|
|
3818
3830
|
#deleteUnreferencedBlobs() {
|
|
@@ -3830,13 +3842,13 @@ export class SyncClient {
|
|
|
3830
3842
|
}
|
|
3831
3843
|
#requireStarted() {
|
|
3832
3844
|
if (!this.#started) {
|
|
3833
|
-
throw invalidRequest('call
|
|
3845
|
+
throw invalidRequest('call start() first');
|
|
3834
3846
|
}
|
|
3835
3847
|
}
|
|
3836
3848
|
#requireActive() {
|
|
3837
3849
|
this.#requireStarted();
|
|
3838
3850
|
if (this.#securityLifecycle === 'preflight') {
|
|
3839
|
-
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, '
|
|
3851
|
+
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'protected reads require activateSecurity');
|
|
3840
3852
|
}
|
|
3841
3853
|
}
|
|
3842
3854
|
}
|
package/dist/failed-overlay.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export declare function retainedBaseWrite(db: ClientDatabase, table: CompiledCli
|
|
|
10
10
|
export declare function retainFailedRows(db: ClientDatabase, commit: OutboxCommit, images: readonly OutboxBeforeImage[], commitSeq?: number): void;
|
|
11
11
|
/** Match declared keys through SQLite, preserving index affinity and NULL semantics. */
|
|
12
12
|
export declare function uniqueConflicts(db: ClientDatabase, table: CompiledClientTable, values: Readonly<Record<string, RowValue>>): NonNullable<RetainedCommitRow['uniqueConflicts']>;
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
export declare function
|
|
13
|
+
/** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
|
|
14
|
+
export type OverlayScope = readonly string[];
|
|
15
|
+
export declare function overlayScopePredicate(table: string, id: string): string;
|
|
16
|
+
export declare function restoreFailedBases(db: ClientDatabase, schema: CompiledClientSchema, scope?: OverlayScope): boolean;
|
|
17
|
+
export declare function failedOverlayCommits(db: ClientDatabase, acknowledged?: boolean, scope?: OverlayScope, schema?: CompiledClientSchema): OutboxCommit[];
|
|
18
|
+
export declare function dropFailedRows(db: ClientDatabase, schema: CompiledClientSchema, matches: (table: string, values: Readonly<Record<string, JsonRowValue>>) => boolean): OutboxCommit[];
|
package/dist/failed-overlay.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
3
|
+
import { OUTBOX_TABLE } from './outbox.js';
|
|
1
4
|
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
2
5
|
import { ClientSyncError } from './errors.js';
|
|
3
6
|
import { mapRowValues, fromSqlValue, jsonToRowValue, quoteIdent, rowValueToJson, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
|
|
@@ -17,7 +20,7 @@ export function retainedBaseWrite(db, table, rowId, values, version) {
|
|
|
17
20
|
table.name,
|
|
18
21
|
rowId,
|
|
19
22
|
]);
|
|
20
|
-
if (!table
|
|
23
|
+
if (!hasUniqueIndex(table))
|
|
21
24
|
return;
|
|
22
25
|
for (const retained of db.query(`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`, [table.name])) {
|
|
23
26
|
const prior = retained.unique_conflicts === null
|
|
@@ -87,28 +90,54 @@ export function uniqueConflicts(db, table, values) {
|
|
|
87
90
|
serverVersion: Number(row[SYNC_VERSION_COLUMN]),
|
|
88
91
|
})));
|
|
89
92
|
}
|
|
90
|
-
function
|
|
91
|
-
return
|
|
93
|
+
export function overlayScopePredicate(table, id) {
|
|
94
|
+
return `?1 IS NULL OR json_array(${table},${id}) IN(SELECT value FROM json_each(?1)) OR json_array(${table}) IN(SELECT value FROM json_each(?1))`;
|
|
92
95
|
}
|
|
93
|
-
|
|
96
|
+
function retainedRows(db, scope, acknowledged) {
|
|
97
|
+
return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`, [scope ? JSON.stringify(scope) : null]);
|
|
98
|
+
}
|
|
99
|
+
function retainedTable(schema, name) {
|
|
100
|
+
const table = schema.tables.get(name);
|
|
101
|
+
if (!table)
|
|
102
|
+
throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
|
|
103
|
+
return table;
|
|
104
|
+
}
|
|
105
|
+
export function restoreFailedBases(db, schema, scope) {
|
|
94
106
|
const seen = new Set();
|
|
95
|
-
for (const row of retainedRows(db,
|
|
107
|
+
for (const row of retainedRows(db, scope)) {
|
|
96
108
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
97
|
-
if (
|
|
109
|
+
if (seen.has(key))
|
|
98
110
|
continue;
|
|
99
111
|
seen.add(key);
|
|
100
|
-
const table = schema
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const table = retainedTable(schema, row.tbl);
|
|
113
|
+
if (row.base === null)
|
|
114
|
+
deleteLocalRow(db, table, row.id, false);
|
|
115
|
+
else {
|
|
116
|
+
const values = JSON.parse(String(row.base));
|
|
117
|
+
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return seen.size > 0;
|
|
121
|
+
}
|
|
122
|
+
export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
|
|
123
|
+
const commits = new Map();
|
|
124
|
+
for (const row of retainedRows(db, scope, acknowledged)) {
|
|
125
|
+
const id = row.commit_id;
|
|
126
|
+
let operation = JSON.parse(row.op);
|
|
127
|
+
if (schema && operation.op === 'upsert') {
|
|
128
|
+
const table = retainedTable(schema, operation.table);
|
|
129
|
+
if (acknowledged) {
|
|
130
|
+
if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
|
|
131
|
+
operation = {
|
|
132
|
+
...operation,
|
|
133
|
+
values: JSON.parse(row.intent),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
else if (row.unique_conflicts === null && row.intent !== null) {
|
|
137
|
+
// Hydrate legacy failure evidence from authoritative unique-key peers.
|
|
138
|
+
const values = JSON.parse(row.intent);
|
|
139
|
+
const authorized = uniqueConflicts(db, table, mapRowValues(values, jsonToRowValue)).filter((conflict) => conflict.serverVersion >= 0 &&
|
|
140
|
+
!db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM ${OUTBOX_TABLE},json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`, [table.name, conflict.rowId, table.name, conflict.rowId]).length);
|
|
112
141
|
if (authorized.length)
|
|
113
142
|
db.exec(`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`, [
|
|
114
143
|
JSON.stringify(authorized.map((conflict) => ({
|
|
@@ -119,35 +148,7 @@ export function restoreFailedBases(db, schema, absentIntent = false, rowKeys) {
|
|
|
119
148
|
row.idx,
|
|
120
149
|
]);
|
|
121
150
|
}
|
|
122
|
-
if (conflicts.length)
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (absentIntent &&
|
|
126
|
-
(json === null ||
|
|
127
|
-
db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [row.id]).length))
|
|
128
|
-
continue;
|
|
129
|
-
if (json === null)
|
|
130
|
-
deleteLocalRow(db, table, row.id, false);
|
|
131
|
-
else {
|
|
132
|
-
const values = JSON.parse(String(json));
|
|
133
|
-
if (absentIntent) {
|
|
134
|
-
const operation = JSON.parse(row.op);
|
|
135
|
-
if (row.commit_seq === null &&
|
|
136
|
-
!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), (row.version ?? -1), false);
|
|
140
151
|
}
|
|
141
|
-
}
|
|
142
|
-
return seen.size > 0;
|
|
143
|
-
}
|
|
144
|
-
export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
145
|
-
const commits = new Map();
|
|
146
|
-
for (const row of retainedRows(db, rowKeys)) {
|
|
147
|
-
if ((row.commit_seq !== null) !== acknowledged)
|
|
148
|
-
continue;
|
|
149
|
-
const id = row.commit_id;
|
|
150
|
-
const operation = JSON.parse(row.op);
|
|
151
152
|
const prior = commits.get(id);
|
|
152
153
|
if (prior)
|
|
153
154
|
prior.operations.push(operation);
|
|
@@ -161,7 +162,7 @@ export function failedOverlayCommits(db, acknowledged = false, rowKeys) {
|
|
|
161
162
|
}
|
|
162
163
|
return [...commits.values()];
|
|
163
164
|
}
|
|
164
|
-
export function dropFailedRows(db, matches) {
|
|
165
|
+
export function dropFailedRows(db, schema, matches) {
|
|
165
166
|
const ids = new Set();
|
|
166
167
|
for (const row of retainedRows(db)) {
|
|
167
168
|
for (const json of [row.intent, row.base]) {
|
|
@@ -176,9 +177,10 @@ export function dropFailedRows(db, matches) {
|
|
|
176
177
|
...failedOverlayCommits(db),
|
|
177
178
|
...failedOverlayCommits(db, true),
|
|
178
179
|
].filter((commit) => ids.has(commit.clientCommitId));
|
|
180
|
+
restoreFailedBases(db, schema, dropped.flatMap((commit) => commit.operations.map((op) => JSON.stringify([op.table, op.rowId]))));
|
|
179
181
|
for (const id of ids) {
|
|
180
182
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
181
|
-
db.exec(
|
|
183
|
+
db.exec(`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`, [id]);
|
|
182
184
|
}
|
|
183
185
|
return dropped;
|
|
184
186
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* (`worker-host`) and the RPC protocol types are root exports — they
|
|
9
9
|
* import no SQLite.
|
|
10
10
|
*/
|
|
11
|
+
export { defineAuthorityReads, type AuthorityReadPolicy, type AuthorityReadDeclaration, type AuthoritySnapshot, } from './authority.js';
|
|
11
12
|
export * from './apply.js';
|
|
12
13
|
export * from './availability.js';
|
|
13
14
|
export * from './blob.js';
|
package/dist/index.js
CHANGED
package/dist/outbox.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export declare const OUTBOX_TABLE = "_syncular_outbox";
|
|
2
|
+
export declare const OUTBOX_IMAGES = "_syncular_outbox_before_images";
|
|
3
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
4
|
/**
|
|
2
5
|
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
6
|
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
@@ -40,7 +43,7 @@ export interface OutboxBeforeImage {
|
|
|
40
43
|
}
|
|
41
44
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
42
45
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
43
|
-
export declare function listOutbox(db: ClientDatabase,
|
|
46
|
+
export declare function listOutbox(db: ClientDatabase, scope?: OverlayScope): OutboxCommit[];
|
|
44
47
|
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
45
48
|
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
46
49
|
/** Routine status reads never load operation bodies. */
|
package/dist/outbox.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
export const OUTBOX_TABLE = '_syncular_outbox';
|
|
3
|
+
export const OUTBOX_IMAGES = '_syncular_outbox_before_images';
|
|
4
|
+
import { overlayScopePredicate } from './failed-overlay.js';
|
|
1
5
|
/**
|
|
2
6
|
* The durable outbox (SPEC.md §7.1) with encode-at-send (the §0 binary-push
|
|
3
7
|
* outbox rule): local mutations are persisted in a schema-agnostic JSON
|
|
@@ -12,15 +16,15 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
12
16
|
if (operations.length === 0) {
|
|
13
17
|
throw new ClientSyncError('sync.empty_commit', 'a local commit must carry at least one operation (§6.1)');
|
|
14
18
|
}
|
|
15
|
-
db.exec(
|
|
19
|
+
db.exec(`INSERT INTO ${OUTBOX_TABLE}(client_commit_id,created_at_ms,operations)VALUES(?,?,?)`, [clientCommitId, nowMs, JSON.stringify(operations)]);
|
|
16
20
|
replaceOutboxBeforeImages(db, clientCommitId, beforeImages);
|
|
17
21
|
}
|
|
18
22
|
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
19
|
-
export function listOutbox(db,
|
|
23
|
+
export function listOutbox(db, scope) {
|
|
20
24
|
return db
|
|
21
|
-
.query(
|
|
22
|
-
?
|
|
23
|
-
:
|
|
25
|
+
.query(scope === undefined
|
|
26
|
+
? `SELECT * FROM ${OUTBOX_TABLE} ORDER BY seq ASC`
|
|
27
|
+
: `SELECT * FROM ${OUTBOX_TABLE} WHERE EXISTS(SELECT 1 FROM json_each(operations) WHERE ${overlayScopePredicate("json_extract(value,'$.table')", "json_extract(value,'$.rowId')")}) ORDER BY seq`, scope && [JSON.stringify(scope)])
|
|
24
28
|
.map(decodeOutboxRow);
|
|
25
29
|
}
|
|
26
30
|
function decodeOutboxRow(row) {
|
|
@@ -35,7 +39,7 @@ function decodeOutboxRow(row) {
|
|
|
35
39
|
export function* iterateOutbox(db, throughSeq) {
|
|
36
40
|
let afterSeq = 0;
|
|
37
41
|
while (afterSeq < throughSeq) {
|
|
38
|
-
const rows = db.query(
|
|
42
|
+
const rows = db.query(`SELECT * FROM ${OUTBOX_TABLE} WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
|
|
39
43
|
if (rows.length === 0)
|
|
40
44
|
return;
|
|
41
45
|
for (const row of rows) {
|
|
@@ -47,21 +51,23 @@ export function* iterateOutbox(db, throughSeq) {
|
|
|
47
51
|
}
|
|
48
52
|
/** Routine status reads never load operation bodies. */
|
|
49
53
|
export function countOutbox(db) {
|
|
50
|
-
return db.query(
|
|
54
|
+
return db.query(`SELECT COUNT(*)AS count FROM ${OUTBOX_TABLE}`)[0]
|
|
51
55
|
.count;
|
|
52
56
|
}
|
|
53
57
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
54
|
-
db.exec(
|
|
58
|
+
db.exec(`DELETE FROM ${BLOB_COMMIT_REFS} WHERE commit_id=?`, [
|
|
55
59
|
clientCommitId,
|
|
56
60
|
]);
|
|
57
|
-
db.exec(
|
|
58
|
-
|
|
61
|
+
db.exec(`DELETE FROM ${OUTBOX_IMAGES} WHERE client_commit_id=?`, [
|
|
62
|
+
clientCommitId,
|
|
63
|
+
]);
|
|
64
|
+
db.exec(`DELETE FROM ${OUTBOX_TABLE} WHERE client_commit_id=?`, [
|
|
59
65
|
clientCommitId,
|
|
60
66
|
]);
|
|
61
67
|
}
|
|
62
68
|
export function listOutboxBeforeImages(db, clientCommitId) {
|
|
63
69
|
return db
|
|
64
|
-
.query(
|
|
70
|
+
.query(`SELECT op_index,existed,sync_version,values_json,delivery_seq FROM ${OUTBOX_IMAGES} WHERE client_commit_id=? ORDER BY op_index`, [clientCommitId])
|
|
65
71
|
.map((row) => ({
|
|
66
72
|
opIndex: row.op_index,
|
|
67
73
|
existed: row.existed === 1,
|
|
@@ -80,7 +86,7 @@ export function listOutboxBeforeImages(db, clientCommitId) {
|
|
|
80
86
|
}
|
|
81
87
|
export function replaceOutboxBeforeImages(db, clientCommitId, replacements) {
|
|
82
88
|
for (const image of replacements) {
|
|
83
|
-
db.exec(
|
|
89
|
+
db.exec(`INSERT INTO ${OUTBOX_IMAGES}(client_commit_id,op_index,existed,sync_version,values_json)VALUES(?,?,?,?,?) ON CONFLICT(client_commit_id,op_index)DO UPDATE SET existed=excluded.existed,sync_version=excluded.sync_version,values_json=excluded.values_json`, [
|
|
84
90
|
clientCommitId,
|
|
85
91
|
image.opIndex,
|
|
86
92
|
image.existed ? 1 : 0,
|
package/dist/outcomes.d.ts
CHANGED
package/dist/outcomes.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
|
|
2
|
+
const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
|
|
1
3
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
4
|
import { ClientSyncError } from './errors.js';
|
|
3
5
|
import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
@@ -74,7 +76,7 @@ function parseOutcome(db, row) {
|
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
export function recordCommitOutcome(db, outcome) {
|
|
77
|
-
db.exec(
|
|
79
|
+
db.exec(`INSERT INTO ${OUTCOMES_TABLE}(client_commit_id,status,recorded_at_ms,results,operations,resolution)VALUES(?,?,?,?,?,'active')`, [
|
|
78
80
|
outcome.clientCommitId,
|
|
79
81
|
outcome.status,
|
|
80
82
|
outcome.recordedAtMs,
|
|
@@ -86,7 +88,9 @@ export function recordCommitOutcome(db, outcome) {
|
|
|
86
88
|
return commitOutcome(db, outcome.clientCommitId);
|
|
87
89
|
}
|
|
88
90
|
export function commitOutcome(db, clientCommitId) {
|
|
89
|
-
const row = db.query(
|
|
91
|
+
const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
|
|
92
|
+
clientCommitId,
|
|
93
|
+
])[0];
|
|
90
94
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
91
95
|
}
|
|
92
96
|
export function listCommitOutcomes(db, query = {}) {
|
|
@@ -97,11 +101,11 @@ export function listCommitOutcomes(db, query = {}) {
|
|
|
97
101
|
const where = query.activeOnly
|
|
98
102
|
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
99
103
|
: '';
|
|
100
|
-
const rows = db.query(
|
|
104
|
+
const rows = db.query(`${SELECT_OUTCOME} ${where} ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
101
105
|
return rows.map((row) => parseOutcome(db, row));
|
|
102
106
|
}
|
|
103
107
|
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
104
|
-
db.exec(
|
|
108
|
+
db.exec(`UPDATE ${OUTCOMES_TABLE} SET resolution=?,resolved_at_ms=?,replacement_client_commit_id=? WHERE client_commit_id=? AND resolution='active'`, [
|
|
105
109
|
input.resolution,
|
|
106
110
|
nowMs,
|
|
107
111
|
input.replacementClientCommitId ?? null,
|
|
@@ -117,13 +121,14 @@ export function pruneCommitOutcomes(db, maxEntries) {
|
|
|
117
121
|
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
118
122
|
throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
|
|
119
123
|
}
|
|
120
|
-
const count = db.query(
|
|
124
|
+
const count = db.query(`SELECT COUNT(*) AS count FROM ${OUTCOMES_TABLE}`)[0]
|
|
125
|
+
?.count;
|
|
121
126
|
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
122
127
|
if (excess === 0)
|
|
123
128
|
return 0;
|
|
124
|
-
const candidates = db.query(
|
|
129
|
+
const candidates = db.query(`SELECT seq FROM ${OUTCOMES_TABLE} WHERE status IN('applied','cached')OR resolution !='active' ORDER BY seq ASC LIMIT ?`, [excess]);
|
|
125
130
|
for (const candidate of candidates) {
|
|
126
|
-
db.exec(
|
|
131
|
+
db.exec(`DELETE FROM ${OUTCOMES_TABLE} WHERE seq=?`, [
|
|
127
132
|
candidate.seq,
|
|
128
133
|
]);
|
|
129
134
|
}
|