@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/src/client.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob';
|
|
2
|
+
import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority';
|
|
3
|
+
import { hasUniqueIndex } from './schema';
|
|
4
|
+
import { OUTCOMES_TABLE } from './outcomes';
|
|
5
|
+
import { OUTBOX_TABLE } from './outbox';
|
|
1
6
|
import { RETAINED_ROWS } from './failed-overlay';
|
|
2
7
|
import {
|
|
3
8
|
dropFailedRows,
|
|
4
9
|
failedOverlayCommits,
|
|
5
10
|
retainFailedRows,
|
|
6
11
|
restoreFailedBases,
|
|
12
|
+
type OverlayScope,
|
|
7
13
|
uniqueConflicts,
|
|
8
14
|
} from './failed-overlay';
|
|
9
15
|
import {
|
|
@@ -419,6 +425,7 @@ export interface SyncClientConfig {
|
|
|
419
425
|
* hosts must not materialize key bytes before their preflight has passed.
|
|
420
426
|
*/
|
|
421
427
|
readonly securityPreflight?: boolean;
|
|
428
|
+
readonly authorityReads?: AuthorityReadPolicy;
|
|
422
429
|
/**
|
|
423
430
|
* RFC 0005 previous-version context. A feature flag, default off: when
|
|
424
431
|
* absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
|
|
@@ -741,6 +748,7 @@ export type ClientSnapshotReader = {
|
|
|
741
748
|
|
|
742
749
|
export class SyncClient {
|
|
743
750
|
readonly #config: SyncClientConfig;
|
|
751
|
+
readonly #authorityReads: AuthorityReadPolicy | undefined;
|
|
744
752
|
readonly #db: ClientDatabase;
|
|
745
753
|
readonly #schema: CompiledClientSchema;
|
|
746
754
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
@@ -855,6 +863,8 @@ export class SyncClient {
|
|
|
855
863
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
856
864
|
this.#db = config.database;
|
|
857
865
|
this.#schema = compileClientSchema(config.schema);
|
|
866
|
+
this.#authorityReads = config.authorityReads;
|
|
867
|
+
this.#authorityReads?.validate(this.#schema);
|
|
858
868
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
859
869
|
if (
|
|
860
870
|
realtimePolicy !== 'required' &&
|
|
@@ -1168,6 +1178,13 @@ export class SyncClient {
|
|
|
1168
1178
|
|
|
1169
1179
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
1170
1180
|
#runLogEpochReset(logEpoch: string): string[] {
|
|
1181
|
+
if (getMeta(this.#db, LOG_EPOCH_META_KEY) === undefined) {
|
|
1182
|
+
this.#applyBatch(() => setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch));
|
|
1183
|
+
this.#setSyncNeeded(true);
|
|
1184
|
+
this.#emitSyncNeeded('startup');
|
|
1185
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1186
|
+
return [];
|
|
1187
|
+
}
|
|
1171
1188
|
const subscriptions = loadSubscriptions(this.#db);
|
|
1172
1189
|
const pending = listOutbox(this.#db);
|
|
1173
1190
|
this.#setUpgrading(true);
|
|
@@ -1220,6 +1237,7 @@ export class SyncClient {
|
|
|
1220
1237
|
}
|
|
1221
1238
|
|
|
1222
1239
|
async close(): Promise<void> {
|
|
1240
|
+
this.#started = false;
|
|
1223
1241
|
this.#emitSyncIntent({ kind: 'none' });
|
|
1224
1242
|
this.#devtoolsUnregister?.();
|
|
1225
1243
|
this.#devtoolsUnregister = undefined;
|
|
@@ -1227,7 +1245,6 @@ export class SyncClient {
|
|
|
1227
1245
|
this.#abortPendingRound('client closed mid-round');
|
|
1228
1246
|
await this.#lease?.release();
|
|
1229
1247
|
this.#lease = undefined;
|
|
1230
|
-
this.#started = false;
|
|
1231
1248
|
this.#progress.clear();
|
|
1232
1249
|
this.#syncNeededListeners.clear();
|
|
1233
1250
|
this.#syncIntentListeners.clear();
|
|
@@ -1386,6 +1403,17 @@ export class SyncClient {
|
|
|
1386
1403
|
return getLocalRevision(this.#db);
|
|
1387
1404
|
}
|
|
1388
1405
|
|
|
1406
|
+
/** Read the creation-time plain authority policy, including preflight. */
|
|
1407
|
+
authoritySnapshot(): AuthoritySnapshot {
|
|
1408
|
+
this.#requireStarted();
|
|
1409
|
+
if (!this.#authorityReads || arguments.length)
|
|
1410
|
+
throw new ClientSyncError(
|
|
1411
|
+
'client.authority_read_forbidden',
|
|
1412
|
+
'authority read denied',
|
|
1413
|
+
);
|
|
1414
|
+
return this.#authorityReads.snapshot(this.#db, this.#schema);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1389
1417
|
/**
|
|
1390
1418
|
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
1391
1419
|
* Reactive integrations use this instead of composing `query()` and
|
|
@@ -1406,28 +1434,11 @@ export class SyncClient {
|
|
|
1406
1434
|
const missing: WindowUnitRef[] = [];
|
|
1407
1435
|
for (const requested of spec.coverage ?? []) {
|
|
1408
1436
|
const baseKey = windowBaseKey(requested.base);
|
|
1409
|
-
const
|
|
1410
|
-
loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
1411
|
-
entry.unit,
|
|
1412
|
-
entry.subId,
|
|
1413
|
-
]),
|
|
1414
|
-
);
|
|
1437
|
+
const state = this.windowState(requested.base);
|
|
1415
1438
|
for (const unit of new Set(requested.units)) {
|
|
1416
|
-
const subId = live.get(unit);
|
|
1417
1439
|
const ref = { baseKey, unit };
|
|
1418
|
-
if (
|
|
1419
|
-
|
|
1420
|
-
continue;
|
|
1421
|
-
}
|
|
1422
|
-
const sub = getSubscription(this.#db, subId);
|
|
1423
|
-
if (
|
|
1424
|
-
sub === undefined ||
|
|
1425
|
-
sub.status !== 'active' ||
|
|
1426
|
-
sub.cursor < 0 ||
|
|
1427
|
-
sub.bootstrapState !== undefined
|
|
1428
|
-
) {
|
|
1429
|
-
pending.push(ref);
|
|
1430
|
-
}
|
|
1440
|
+
if (!state.units.includes(unit)) missing.push(ref);
|
|
1441
|
+
else if (state.pending.includes(unit)) pending.push(ref);
|
|
1431
1442
|
}
|
|
1432
1443
|
}
|
|
1433
1444
|
return {
|
|
@@ -1805,7 +1816,7 @@ export class SyncClient {
|
|
|
1805
1816
|
item.table.length === 0
|
|
1806
1817
|
) {
|
|
1807
1818
|
throw invalidRequest(
|
|
1808
|
-
'diagnosticsSnapshot
|
|
1819
|
+
'diagnosticsSnapshot needs subscription id and table',
|
|
1809
1820
|
);
|
|
1810
1821
|
}
|
|
1811
1822
|
const registeredSubscription = subscriptions.get(item.id);
|
|
@@ -1917,11 +1928,11 @@ export class SyncClient {
|
|
|
1917
1928
|
);
|
|
1918
1929
|
const outboxBytes = Number(
|
|
1919
1930
|
this.#db.query(
|
|
1920
|
-
|
|
1931
|
+
`SELECT COALESCE(SUM(LENGTH(operations)),0)AS bytes FROM ${OUTBOX_TABLE}`,
|
|
1921
1932
|
)[0]?.bytes ?? 0,
|
|
1922
1933
|
);
|
|
1923
1934
|
const outcome = this.#db.query(
|
|
1924
|
-
|
|
1935
|
+
`SELECT COUNT(*)AS entries,COALESCE(SUM(LENGTH(results)+ COALESCE(LENGTH(operations),0)),0)AS bytes FROM ${OUTCOMES_TABLE}`,
|
|
1925
1936
|
)[0];
|
|
1926
1937
|
const blobBytes = this.#hasBlobs
|
|
1927
1938
|
? Number(
|
|
@@ -2894,7 +2905,7 @@ export class SyncClient {
|
|
|
2894
2905
|
continue;
|
|
2895
2906
|
}
|
|
2896
2907
|
this.#db.exec(
|
|
2897
|
-
|
|
2908
|
+
`INSERT OR IGNORE INTO ${BLOB_COMMIT_REFS}(commit_id,blob_id)SELECT ?,? WHERE EXISTS(SELECT 1 FROM _syncular_blobs WHERE blob_id=?)`,
|
|
2898
2909
|
[clientCommitId, blobId, blobId],
|
|
2899
2910
|
);
|
|
2900
2911
|
}
|
|
@@ -3158,9 +3169,7 @@ export class SyncClient {
|
|
|
3158
3169
|
};
|
|
3159
3170
|
}
|
|
3160
3171
|
if (this.#schemaFloor !== undefined) {
|
|
3161
|
-
throw invalidRequest(
|
|
3162
|
-
'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
|
|
3163
|
-
);
|
|
3172
|
+
throw invalidRequest('local rebootstrap needs a schema-floor update');
|
|
3164
3173
|
}
|
|
3165
3174
|
|
|
3166
3175
|
const pending = listOutbox(this.#db);
|
|
@@ -3295,7 +3304,7 @@ export class SyncClient {
|
|
|
3295
3304
|
// Pin before the first encryption await: mutations can append while a
|
|
3296
3305
|
// round is encoding, and belong to the next request.
|
|
3297
3306
|
const bounds = this.#db.query(
|
|
3298
|
-
|
|
3307
|
+
`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`,
|
|
3299
3308
|
)[0]!;
|
|
3300
3309
|
const pendingCount = bounds.count as number;
|
|
3301
3310
|
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
@@ -4398,13 +4407,7 @@ export class SyncClient {
|
|
|
4398
4407
|
if (errorFrame !== undefined) break;
|
|
4399
4408
|
}
|
|
4400
4409
|
} finally {
|
|
4401
|
-
|
|
4402
|
-
// §7.1: local reads see outbox state applied optimistically — replay
|
|
4403
|
-
// the still-pending commits on top of the freshly applied server state.
|
|
4404
|
-
this.#replayOutbox();
|
|
4405
|
-
} finally {
|
|
4406
|
-
this.#endDiagnosticsDeferral();
|
|
4407
|
-
}
|
|
4410
|
+
this.#endDiagnosticsDeferral();
|
|
4408
4411
|
}
|
|
4409
4412
|
|
|
4410
4413
|
if (errorFrame !== undefined) throw errorFrame;
|
|
@@ -4558,6 +4561,15 @@ export class SyncClient {
|
|
|
4558
4561
|
listOutboxBeforeImages(this.#db, commit.clientCommitId),
|
|
4559
4562
|
);
|
|
4560
4563
|
this.#rollbackFailedCommit(commit, batch);
|
|
4564
|
+
this.#replayOutbox(
|
|
4565
|
+
commit.operations.map((operation) =>
|
|
4566
|
+
JSON.stringify(
|
|
4567
|
+
hasUniqueIndex(this.#table(operation.table))
|
|
4568
|
+
? [operation.table]
|
|
4569
|
+
: [operation.table, operation.rowId],
|
|
4570
|
+
),
|
|
4571
|
+
),
|
|
4572
|
+
);
|
|
4561
4573
|
});
|
|
4562
4574
|
batch.status();
|
|
4563
4575
|
summary.rejected.push(frame.clientCommitId);
|
|
@@ -4567,7 +4579,7 @@ export class SyncClient {
|
|
|
4567
4579
|
#outboxCommitExists(clientCommitId: string): boolean {
|
|
4568
4580
|
return (
|
|
4569
4581
|
this.#db.query(
|
|
4570
|
-
|
|
4582
|
+
`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`,
|
|
4571
4583
|
[clientCommitId],
|
|
4572
4584
|
).length > 0
|
|
4573
4585
|
);
|
|
@@ -4621,7 +4633,7 @@ export class SyncClient {
|
|
|
4621
4633
|
this.#applyBatch((batch) => {
|
|
4622
4634
|
this.#recordCommitChanges(batch, frame);
|
|
4623
4635
|
const result = fn();
|
|
4624
|
-
this.#replayOutbox(result);
|
|
4636
|
+
this.#replayOutbox(result, false);
|
|
4625
4637
|
return result;
|
|
4626
4638
|
}),
|
|
4627
4639
|
);
|
|
@@ -4704,7 +4716,7 @@ export class SyncClient {
|
|
|
4704
4716
|
)
|
|
4705
4717
|
batch.table(table.name);
|
|
4706
4718
|
const result = fn();
|
|
4707
|
-
this.#replayOutbox();
|
|
4719
|
+
this.#replayOutbox(result, false);
|
|
4708
4720
|
return result;
|
|
4709
4721
|
}),
|
|
4710
4722
|
};
|
|
@@ -4888,14 +4900,17 @@ export class SyncClient {
|
|
|
4888
4900
|
}),
|
|
4889
4901
|
);
|
|
4890
4902
|
if (covered.length) {
|
|
4891
|
-
|
|
4903
|
+
const scope = covered.map((row) =>
|
|
4904
|
+
JSON.stringify([sub.table, String(row.id)]),
|
|
4905
|
+
);
|
|
4906
|
+
restoreFailedBases(this.#db, this.#schema, scope);
|
|
4892
4907
|
for (const row of covered)
|
|
4893
4908
|
this.#db.exec(
|
|
4894
4909
|
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
4895
4910
|
[sub.table, String(row.id), nextCursor],
|
|
4896
4911
|
);
|
|
4897
4912
|
batch.table(sub.table);
|
|
4898
|
-
this.#replayOutbox();
|
|
4913
|
+
this.#replayOutbox(scope, false);
|
|
4899
4914
|
}
|
|
4900
4915
|
}
|
|
4901
4916
|
if (completed && registered !== undefined) {
|
|
@@ -4945,13 +4960,15 @@ export class SyncClient {
|
|
|
4945
4960
|
Object.keys(lastEffective).length > 0
|
|
4946
4961
|
) {
|
|
4947
4962
|
try {
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
name
|
|
4953
|
-
|
|
4954
|
-
|
|
4963
|
+
const scope = [
|
|
4964
|
+
JSON.stringify([table.name]),
|
|
4965
|
+
...this.#dropRetainedRows(
|
|
4966
|
+
batch,
|
|
4967
|
+
(name, values) =>
|
|
4968
|
+
name === table.name &&
|
|
4969
|
+
matchesLocalScopes(table, lastEffective, values),
|
|
4970
|
+
),
|
|
4971
|
+
];
|
|
4955
4972
|
deleteScopedRows(this.#db, table, lastEffective);
|
|
4956
4973
|
batch.scopeMap(table, lastEffective);
|
|
4957
4974
|
const pendingById = new Map(
|
|
@@ -4997,6 +5014,7 @@ export class SyncClient {
|
|
|
4997
5014
|
batch.rejections();
|
|
4998
5015
|
batch.outcomes();
|
|
4999
5016
|
}
|
|
5017
|
+
this.#replayOutbox(scope);
|
|
5000
5018
|
this.#deleteUnreferencedBlobs();
|
|
5001
5019
|
} catch (error) {
|
|
5002
5020
|
if (
|
|
@@ -5217,7 +5235,7 @@ export class SyncClient {
|
|
|
5217
5235
|
: (local[SYNC_VERSION_COLUMN] as number);
|
|
5218
5236
|
if (
|
|
5219
5237
|
replay &&
|
|
5220
|
-
table
|
|
5238
|
+
hasUniqueIndex(table) &&
|
|
5221
5239
|
uniqueConflicts(
|
|
5222
5240
|
this.#db,
|
|
5223
5241
|
table,
|
|
@@ -5245,8 +5263,9 @@ export class SyncClient {
|
|
|
5245
5263
|
table: string,
|
|
5246
5264
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
5247
5265
|
) => boolean,
|
|
5248
|
-
):
|
|
5249
|
-
|
|
5266
|
+
): string[] {
|
|
5267
|
+
const scope: string[] = [];
|
|
5268
|
+
for (const commit of dropFailedRows(this.#db, this.#schema, matches)) {
|
|
5250
5269
|
batch.conflicts();
|
|
5251
5270
|
batch.rejections();
|
|
5252
5271
|
batch.outcomes();
|
|
@@ -5256,32 +5275,39 @@ export class SyncClient {
|
|
|
5256
5275
|
this.#rejections = this.#rejections.filter(
|
|
5257
5276
|
(record) => record.clientCommitId !== commit.clientCommitId,
|
|
5258
5277
|
);
|
|
5259
|
-
for (const op of commit.operations)
|
|
5278
|
+
for (const op of commit.operations) {
|
|
5279
|
+
batch.table(op.table);
|
|
5280
|
+
scope.push(JSON.stringify([op.table, op.rowId]));
|
|
5281
|
+
}
|
|
5260
5282
|
}
|
|
5283
|
+
return scope;
|
|
5261
5284
|
}
|
|
5262
5285
|
|
|
5263
|
-
#replayOutbox(
|
|
5264
|
-
|
|
5265
|
-
const pending = [
|
|
5266
|
-
...failedOverlayCommits(this.#db, false, rowKeys),
|
|
5267
|
-
...listOutbox(this.#db, rowKeys),
|
|
5268
|
-
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5269
|
-
if (pending.length === 0 && acknowledged.length === 0) return;
|
|
5286
|
+
#replayOutbox(scope?: OverlayScope, restoreBases = true): void {
|
|
5287
|
+
if (scope?.length === 0) return;
|
|
5270
5288
|
this.#applyBatch((batch) => {
|
|
5271
|
-
this.#db
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5289
|
+
if (restoreBases) restoreFailedBases(this.#db, this.#schema, scope);
|
|
5290
|
+
const acknowledged = failedOverlayCommits(
|
|
5291
|
+
this.#db,
|
|
5292
|
+
true,
|
|
5293
|
+
scope,
|
|
5294
|
+
this.#schema,
|
|
5295
|
+
);
|
|
5296
|
+
const pending = [
|
|
5297
|
+
...failedOverlayCommits(this.#db, false, scope, this.#schema),
|
|
5298
|
+
...listOutbox(this.#db, scope),
|
|
5299
|
+
].sort((a, b) => a.createdAtMs - b.createdAtMs || a.seq - b.seq);
|
|
5300
|
+
for (const commit of [...acknowledged, ...pending])
|
|
5301
|
+
this.#applyOperationsLocally(
|
|
5302
|
+
commit.operations.filter(
|
|
5303
|
+
(op) =>
|
|
5304
|
+
scope === undefined ||
|
|
5305
|
+
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
5306
|
+
scope.includes(JSON.stringify([op.table])),
|
|
5307
|
+
),
|
|
5308
|
+
batch,
|
|
5309
|
+
true,
|
|
5310
|
+
);
|
|
5285
5311
|
});
|
|
5286
5312
|
}
|
|
5287
5313
|
|
|
@@ -5302,7 +5328,7 @@ export class SyncClient {
|
|
|
5302
5328
|
|
|
5303
5329
|
#requireStarted(): void {
|
|
5304
5330
|
if (!this.#started) {
|
|
5305
|
-
throw invalidRequest('call
|
|
5331
|
+
throw invalidRequest('call start() first');
|
|
5306
5332
|
}
|
|
5307
5333
|
}
|
|
5308
5334
|
|
|
@@ -5311,7 +5337,7 @@ export class SyncClient {
|
|
|
5311
5337
|
if (this.#securityLifecycle === 'preflight') {
|
|
5312
5338
|
throw new ClientSyncError(
|
|
5313
5339
|
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
5314
|
-
'
|
|
5340
|
+
'protected reads require activateSecurity',
|
|
5315
5341
|
);
|
|
5316
5342
|
}
|
|
5317
5343
|
}
|
package/src/failed-overlay.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema';
|
|
2
|
+
import { OUTCOMES_TABLE } from './outcomes';
|
|
3
|
+
import { OUTBOX_TABLE } from './outbox';
|
|
1
4
|
export const RETAINED_ROWS = '_syncular_failed_rows';
|
|
2
5
|
import { ClientSyncError } from './errors';
|
|
3
6
|
import type { ClientDatabase } from './database';
|
|
@@ -48,7 +51,7 @@ export function retainedBaseWrite(
|
|
|
48
51
|
table.name,
|
|
49
52
|
rowId,
|
|
50
53
|
]);
|
|
51
|
-
if (!table
|
|
54
|
+
if (!hasUniqueIndex(table)) return;
|
|
52
55
|
for (const retained of db.query(
|
|
53
56
|
`SELECT commit_id,idx,intent,unique_conflicts FROM ${RETAINED_ROWS} WHERE tbl=? AND commit_seq IS NULL`,
|
|
54
57
|
[table.name],
|
|
@@ -164,90 +167,51 @@ export function uniqueConflicts(
|
|
|
164
167
|
);
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
|
|
170
|
+
/** Exact row keys are JSON [table,id]; table scopes include unique-key peers. */
|
|
171
|
+
export type OverlayScope = readonly string[];
|
|
172
|
+
|
|
173
|
+
export function overlayScopePredicate(table: string, id: string): string {
|
|
174
|
+
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))`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function retainedRows(
|
|
178
|
+
db: ClientDatabase,
|
|
179
|
+
scope?: OverlayScope,
|
|
180
|
+
acknowledged?: boolean,
|
|
181
|
+
) {
|
|
168
182
|
return db.query(
|
|
169
|
-
`SELECT * FROM ${RETAINED_ROWS} WHERE
|
|
170
|
-
[
|
|
183
|
+
`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`,
|
|
184
|
+
[scope ? JSON.stringify(scope) : null],
|
|
171
185
|
);
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
function retainedTable(
|
|
189
|
+
schema: CompiledClientSchema,
|
|
190
|
+
name: string,
|
|
191
|
+
): CompiledClientTable {
|
|
192
|
+
const table = schema.tables.get(name);
|
|
193
|
+
if (!table)
|
|
194
|
+
throw new ClientSyncError(
|
|
195
|
+
'sync.unknown_table',
|
|
196
|
+
'retained table is unknown',
|
|
197
|
+
);
|
|
198
|
+
return table;
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
export function restoreFailedBases(
|
|
175
202
|
db: ClientDatabase,
|
|
176
203
|
schema: CompiledClientSchema,
|
|
177
|
-
|
|
178
|
-
rowKeys?: readonly string[],
|
|
204
|
+
scope?: OverlayScope,
|
|
179
205
|
): boolean {
|
|
180
206
|
const seen = new Set<string>();
|
|
181
|
-
for (const row of retainedRows(db,
|
|
207
|
+
for (const row of retainedRows(db, scope)) {
|
|
182
208
|
const key = JSON.stringify([row.tbl, row.id]);
|
|
183
|
-
if (
|
|
209
|
+
if (seen.has(key)) continue;
|
|
184
210
|
seen.add(key);
|
|
185
|
-
const table = schema
|
|
186
|
-
if (
|
|
187
|
-
throw new ClientSyncError(
|
|
188
|
-
'sync.unknown_table',
|
|
189
|
-
'retained table is unknown',
|
|
190
|
-
);
|
|
191
|
-
const json = absentIntent ? row.intent : row.base;
|
|
192
|
-
if (absentIntent && row.intent !== null) {
|
|
193
|
-
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
194
|
-
row.intent as string,
|
|
195
|
-
);
|
|
196
|
-
const conflicts = uniqueConflicts(
|
|
197
|
-
db,
|
|
198
|
-
table,
|
|
199
|
-
mapRowValues(values, jsonToRowValue),
|
|
200
|
-
);
|
|
201
|
-
// Hydrate a pre-0.30.11 failure after its winner was already imported.
|
|
202
|
-
// Pending and retained overlays cannot supply authoritative row evidence.
|
|
203
|
-
if (row.commit_seq === null && row.unique_conflicts === null) {
|
|
204
|
-
const authorized = conflicts.filter(
|
|
205
|
-
(conflict) =>
|
|
206
|
-
conflict.serverVersion >= 0 &&
|
|
207
|
-
!db.query(
|
|
208
|
-
`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? UNION ALL SELECT 1 FROM _syncular_outbox,json_each(operations)WHERE json_extract(json_each.value,'$.table')=? AND json_extract(json_each.value,'$.rowId')=? LIMIT 1`,
|
|
209
|
-
[table.name, conflict.rowId, table.name, conflict.rowId],
|
|
210
|
-
).length,
|
|
211
|
-
);
|
|
212
|
-
if (authorized.length)
|
|
213
|
-
db.exec(
|
|
214
|
-
`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
|
|
215
|
-
[
|
|
216
|
-
JSON.stringify(
|
|
217
|
-
authorized.map((conflict) => ({
|
|
218
|
-
...conflict,
|
|
219
|
-
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
220
|
-
})),
|
|
221
|
-
),
|
|
222
|
-
row.commit_id as string,
|
|
223
|
-
row.idx as number,
|
|
224
|
-
],
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
if (conflicts.length) continue;
|
|
228
|
-
}
|
|
229
|
-
if (
|
|
230
|
-
absentIntent &&
|
|
231
|
-
(json === null ||
|
|
232
|
-
db.query(
|
|
233
|
-
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
234
|
-
[row.id as string],
|
|
235
|
-
).length)
|
|
236
|
-
)
|
|
237
|
-
continue;
|
|
238
|
-
if (json === null) deleteLocalRow(db, table, row.id as string, false);
|
|
211
|
+
const table = retainedTable(schema, row.tbl as string);
|
|
212
|
+
if (row.base === null) deleteLocalRow(db, table, row.id as string, false);
|
|
239
213
|
else {
|
|
240
|
-
const values: Record<string, JsonRowValue> = JSON.parse(String(
|
|
241
|
-
if (absentIntent) {
|
|
242
|
-
const operation: OutboxOperation = JSON.parse(row.op as string);
|
|
243
|
-
if (
|
|
244
|
-
row.commit_seq === null &&
|
|
245
|
-
!table.columns.every((column) =>
|
|
246
|
-
Object.hasOwn(operation.values ?? {}, column.name),
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
214
|
+
const values: Record<string, JsonRowValue> = JSON.parse(String(row.base));
|
|
251
215
|
upsertLocalRow(
|
|
252
216
|
db,
|
|
253
217
|
table,
|
|
@@ -265,7 +229,8 @@ export function restoreFailedBases(
|
|
|
265
229
|
export function failedOverlayCommits(
|
|
266
230
|
db: ClientDatabase,
|
|
267
231
|
acknowledged = false,
|
|
268
|
-
|
|
232
|
+
scope?: OverlayScope,
|
|
233
|
+
schema?: CompiledClientSchema,
|
|
269
234
|
): OutboxCommit[] {
|
|
270
235
|
const commits = new Map<
|
|
271
236
|
string,
|
|
@@ -276,10 +241,55 @@ export function failedOverlayCommits(
|
|
|
276
241
|
operations: OutboxOperation[];
|
|
277
242
|
}
|
|
278
243
|
>();
|
|
279
|
-
for (const row of retainedRows(db,
|
|
280
|
-
if ((row.commit_seq !== null) !== acknowledged) continue;
|
|
244
|
+
for (const row of retainedRows(db, scope, acknowledged)) {
|
|
281
245
|
const id = row.commit_id as string;
|
|
282
|
-
|
|
246
|
+
let operation: OutboxOperation = JSON.parse(row.op as string);
|
|
247
|
+
if (schema && operation.op === 'upsert') {
|
|
248
|
+
const table = retainedTable(schema, operation.table);
|
|
249
|
+
if (acknowledged) {
|
|
250
|
+
if (
|
|
251
|
+
!db.query(
|
|
252
|
+
`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`,
|
|
253
|
+
[operation.rowId],
|
|
254
|
+
).length
|
|
255
|
+
)
|
|
256
|
+
operation = {
|
|
257
|
+
...operation,
|
|
258
|
+
values: JSON.parse(row.intent as string),
|
|
259
|
+
};
|
|
260
|
+
} else if (row.unique_conflicts === null && row.intent !== null) {
|
|
261
|
+
// Hydrate legacy failure evidence from authoritative unique-key peers.
|
|
262
|
+
const values: Record<string, JsonRowValue> = JSON.parse(
|
|
263
|
+
row.intent as string,
|
|
264
|
+
);
|
|
265
|
+
const authorized = uniqueConflicts(
|
|
266
|
+
db,
|
|
267
|
+
table,
|
|
268
|
+
mapRowValues(values, jsonToRowValue),
|
|
269
|
+
).filter(
|
|
270
|
+
(conflict) =>
|
|
271
|
+
conflict.serverVersion >= 0 &&
|
|
272
|
+
!db.query(
|
|
273
|
+
`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`,
|
|
274
|
+
[table.name, conflict.rowId, table.name, conflict.rowId],
|
|
275
|
+
).length,
|
|
276
|
+
);
|
|
277
|
+
if (authorized.length)
|
|
278
|
+
db.exec(
|
|
279
|
+
`UPDATE ${RETAINED_ROWS} SET unique_conflicts=? WHERE commit_id=? AND idx=?`,
|
|
280
|
+
[
|
|
281
|
+
JSON.stringify(
|
|
282
|
+
authorized.map((conflict) => ({
|
|
283
|
+
...conflict,
|
|
284
|
+
serverRow: mapRowValues(conflict.serverRow, rowValueToJson),
|
|
285
|
+
})),
|
|
286
|
+
),
|
|
287
|
+
row.commit_id as string,
|
|
288
|
+
row.idx as number,
|
|
289
|
+
],
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
283
293
|
const prior = commits.get(id);
|
|
284
294
|
if (prior) prior.operations.push(operation);
|
|
285
295
|
else
|
|
@@ -295,6 +305,7 @@ export function failedOverlayCommits(
|
|
|
295
305
|
|
|
296
306
|
export function dropFailedRows(
|
|
297
307
|
db: ClientDatabase,
|
|
308
|
+
schema: CompiledClientSchema,
|
|
298
309
|
matches: (
|
|
299
310
|
table: string,
|
|
300
311
|
values: Readonly<Record<string, JsonRowValue>>,
|
|
@@ -312,10 +323,17 @@ export function dropFailedRows(
|
|
|
312
323
|
...failedOverlayCommits(db),
|
|
313
324
|
...failedOverlayCommits(db, true),
|
|
314
325
|
].filter((commit) => ids.has(commit.clientCommitId));
|
|
326
|
+
restoreFailedBases(
|
|
327
|
+
db,
|
|
328
|
+
schema,
|
|
329
|
+
dropped.flatMap((commit) =>
|
|
330
|
+
commit.operations.map((op) => JSON.stringify([op.table, op.rowId])),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
315
333
|
for (const id of ids) {
|
|
316
334
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE commit_id=?`, [id]);
|
|
317
335
|
db.exec(
|
|
318
|
-
|
|
336
|
+
`UPDATE ${OUTCOMES_TABLE} SET operations=NULL,results='[]' WHERE client_commit_id=?`,
|
|
319
337
|
[id],
|
|
320
338
|
);
|
|
321
339
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* (`worker-host`) and the RPC protocol types are root exports — they
|
|
9
9
|
* import no SQLite.
|
|
10
10
|
*/
|
|
11
|
+
export {
|
|
12
|
+
defineAuthorityReads,
|
|
13
|
+
type AuthorityReadPolicy,
|
|
14
|
+
type AuthorityReadDeclaration,
|
|
15
|
+
type AuthoritySnapshot,
|
|
16
|
+
} from './authority';
|
|
11
17
|
export * from './apply';
|
|
12
18
|
export * from './availability';
|
|
13
19
|
export * from './blob';
|