@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/previous-version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ClientSyncError } from './errors.js';
|
|
2
2
|
import { INVALID_HOST_RESPONSE_CODE } from './local-rebootstrap.js';
|
|
3
3
|
import { fromSqlValue, jsonToRowValue, localColumnType, quoteIdent, rowValueToJson, } from './schema.js';
|
|
4
|
-
import { getMeta, setMeta } from './state.js';
|
|
4
|
+
import { getMeta, setMeta, META_TABLE } from './state.js';
|
|
5
5
|
/** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
|
|
6
6
|
const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
|
|
7
7
|
/**
|
|
@@ -441,7 +441,7 @@ export function writePreviousVersionRefusal(db, reason, measurement) {
|
|
|
441
441
|
}
|
|
442
442
|
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
443
443
|
export function clearPreviousVersionRefusal(db) {
|
|
444
|
-
db.exec(
|
|
444
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
445
445
|
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
446
446
|
]);
|
|
447
447
|
}
|
|
@@ -575,7 +575,7 @@ export function writePreviousVersionAudit(db, audit) {
|
|
|
575
575
|
}
|
|
576
576
|
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
577
577
|
export function clearPreviousVersionAudit(db) {
|
|
578
|
-
db.exec(
|
|
578
|
+
db.exec(`DELETE FROM ${META_TABLE} WHERE key=?`, [
|
|
579
579
|
PREVIOUS_VERSION_AUDIT_KEY,
|
|
580
580
|
]);
|
|
581
581
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ export declare const OPTIMISTIC_VERSION = -1;
|
|
|
93
93
|
* pass through this filter.
|
|
94
94
|
*/
|
|
95
95
|
export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
|
|
96
|
+
export declare function hasUniqueIndex(table: CompiledClientTable): boolean;
|
|
96
97
|
export declare function matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
|
|
97
98
|
export declare function quoteIdent(name: string): string;
|
|
98
99
|
/**
|
package/dist/schema.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { BLOB_COMMIT_REFS } from './blob.js';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window.js';
|
|
3
|
+
import { WINDOWS_TABLE } from './window.js';
|
|
4
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES } from './outbox.js';
|
|
5
|
+
import { OUTCOMES_TABLE } from './outcomes.js';
|
|
6
|
+
import { SUBSCRIPTIONS_TABLE, META_TABLE } from './state.js';
|
|
1
7
|
import { ensureFailedOverlaySchema } from './failed-overlay.js';
|
|
2
8
|
import { invalidRequest } from './errors.js';
|
|
3
9
|
import { snakeToCamel } from './naming.js';
|
|
@@ -156,6 +162,9 @@ export function stripSyncColumns(rows) {
|
|
|
156
162
|
return copy;
|
|
157
163
|
});
|
|
158
164
|
}
|
|
165
|
+
export function hasUniqueIndex(table) {
|
|
166
|
+
return table.indexes.some((index) => index.unique);
|
|
167
|
+
}
|
|
159
168
|
export function matchesLocalScopes(table, scopes, values) {
|
|
160
169
|
const entries = Object.entries(scopes);
|
|
161
170
|
return (entries.length > 0 &&
|
|
@@ -361,34 +370,34 @@ export function ensureLocalBookkeepingSchema(db) {
|
|
|
361
370
|
db.transaction(() => {
|
|
362
371
|
ensureFailedOverlaySchema(db);
|
|
363
372
|
const createTable = (name, columns) => db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
|
|
364
|
-
createTable(
|
|
365
|
-
createTable(
|
|
366
|
-
db.exec(
|
|
367
|
-
db.exec(
|
|
368
|
-
createTable(
|
|
369
|
-
createTable(
|
|
373
|
+
createTable(META_TABLE, 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
|
|
374
|
+
createTable(BLOB_COMMIT_REFS, 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
|
|
375
|
+
db.exec(`CREATE INDEX IF NOT EXISTS ${BLOB_COMMIT_REFS}_body ON ${BLOB_COMMIT_REFS}(blob_id)`);
|
|
376
|
+
db.exec(`INSERT OR IGNORE INTO ${META_TABLE}(key,value)VALUES('localRevision','0')`);
|
|
377
|
+
createTable(OUTBOX_TABLE, 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
|
|
378
|
+
createTable(OUTBOX_IMAGES, 'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)');
|
|
370
379
|
if (!db
|
|
371
|
-
.query(
|
|
380
|
+
.query(`PRAGMA table_info(${OUTBOX_IMAGES})`)
|
|
372
381
|
.some((row) => row.name === 'delivery_seq'))
|
|
373
|
-
db.exec(
|
|
374
|
-
createTable(
|
|
382
|
+
db.exec(`ALTER TABLE ${OUTBOX_IMAGES} ADD COLUMN delivery_seq INTEGER`);
|
|
383
|
+
createTable(OUTCOMES_TABLE, "seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT");
|
|
375
384
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
376
385
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
377
386
|
try {
|
|
378
|
-
db.exec(
|
|
387
|
+
db.exec(`ALTER TABLE ${OUTCOMES_TABLE} ADD COLUMN operations TEXT`);
|
|
379
388
|
}
|
|
380
389
|
catch {
|
|
381
390
|
// column already exists — the CREATE above included it
|
|
382
391
|
}
|
|
383
|
-
db.exec(
|
|
384
|
-
createTable(
|
|
392
|
+
db.exec(`CREATE INDEX IF NOT EXISTS ${OUTCOMES_TABLE}_resolution_seq ON ${OUTCOMES_TABLE}(resolution,seq)`);
|
|
393
|
+
createTable(SUBSCRIPTIONS_TABLE, "id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT");
|
|
385
394
|
// §4.8 window registry: which units (scope values) of a window base are
|
|
386
395
|
// live locally — the completeness oracle (I3) and the shrink driver
|
|
387
396
|
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
388
|
-
createTable(
|
|
397
|
+
createTable(WINDOWS_TABLE, 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
|
|
389
398
|
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
390
399
|
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
391
|
-
createTable(
|
|
400
|
+
createTable(PENDING_EVICTIONS, 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
|
|
392
401
|
});
|
|
393
402
|
}
|
|
394
403
|
/** Create both application and protected bookkeeping tables. */
|
package/dist/state.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const META_TABLE = "_syncular_meta";
|
|
2
|
+
export declare const SUBSCRIPTIONS_TABLE = "_syncular_subscriptions";
|
|
1
3
|
/**
|
|
2
4
|
* Durable client sync state: per-subscription cursor, bootstrap resume
|
|
3
5
|
* token (round-tripped opaquely, §4.7), and the last-echoed effective
|
package/dist/state.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const META_TABLE = '_syncular_meta';
|
|
2
|
+
import { PENDING_EVICTIONS } from './window.js';
|
|
3
|
+
import { WINDOWS_TABLE } from './window.js';
|
|
4
|
+
export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
|
|
1
5
|
export const LOCAL_REVISION_KEY = 'localRevision';
|
|
2
6
|
const MAX_U64 = 18446744073709551615n;
|
|
3
7
|
function rowToRecord(row) {
|
|
@@ -23,17 +27,17 @@ function rowToRecord(row) {
|
|
|
23
27
|
}
|
|
24
28
|
export function loadSubscriptions(db) {
|
|
25
29
|
return db
|
|
26
|
-
.query(
|
|
30
|
+
.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} ORDER BY rowid ASC`)
|
|
27
31
|
.map(rowToRecord);
|
|
28
32
|
}
|
|
29
33
|
export function getSubscription(db, id) {
|
|
30
|
-
const row = db.query(
|
|
34
|
+
const row = db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [
|
|
31
35
|
id,
|
|
32
36
|
])[0];
|
|
33
37
|
return row === undefined ? undefined : rowToRecord(row);
|
|
34
38
|
}
|
|
35
39
|
export function saveSubscription(db, record) {
|
|
36
|
-
db.exec(
|
|
40
|
+
db.exec(`INSERT OR REPLACE INTO ${SUBSCRIPTIONS_TABLE}(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)`, [
|
|
37
41
|
record.id,
|
|
38
42
|
record.table,
|
|
39
43
|
JSON.stringify(record.scopes),
|
|
@@ -48,7 +52,7 @@ export function saveSubscription(db, record) {
|
|
|
48
52
|
]);
|
|
49
53
|
}
|
|
50
54
|
export function deleteSubscription(db, id) {
|
|
51
|
-
db.exec(
|
|
55
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
|
|
@@ -58,7 +62,7 @@ export function deleteSubscription(db, id) {
|
|
|
58
62
|
* subscriptions the app still wants. Caller owns the transaction.
|
|
59
63
|
*/
|
|
60
64
|
export function resetSubscriptionsForBump(db) {
|
|
61
|
-
db.exec(
|
|
65
|
+
db.exec(`UPDATE ${SUBSCRIPTIONS_TABLE} SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL`);
|
|
62
66
|
}
|
|
63
67
|
/** Persist declarations separately from the previous-version row descriptor. */
|
|
64
68
|
export function saveSubscriptionScopeSchema(db, schema) {
|
|
@@ -108,7 +112,7 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
|
|
|
108
112
|
.map((record) => record.id));
|
|
109
113
|
// Shrinking a pinned window already removed its registration. Its deferred
|
|
110
114
|
// eviction still carries the old scope declaration and needs the same fence.
|
|
111
|
-
for (const row of db.query(
|
|
115
|
+
for (const row of db.query(`SELECT sub_id,tbl,effective_scopes FROM ${PENDING_EVICTIONS}`)) {
|
|
112
116
|
if (!compatible({
|
|
113
117
|
table: String(row.tbl),
|
|
114
118
|
scopes: JSON.parse(String(row.effective_scopes)),
|
|
@@ -119,23 +123,19 @@ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = fals
|
|
|
119
123
|
return retained;
|
|
120
124
|
db.transaction(() => {
|
|
121
125
|
for (const id of staleIds) {
|
|
122
|
-
db.exec(
|
|
123
|
-
db.exec(
|
|
124
|
-
|
|
125
|
-
]);
|
|
126
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
126
|
+
db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE sub_id=?`, [id]);
|
|
127
|
+
db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id=?`, [id]);
|
|
128
|
+
db.exec(`DELETE FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id]);
|
|
127
129
|
}
|
|
128
130
|
});
|
|
129
131
|
return retained;
|
|
130
132
|
}
|
|
131
133
|
export function getMeta(db, key) {
|
|
132
|
-
const row = db.query(
|
|
133
|
-
key,
|
|
134
|
-
])[0];
|
|
134
|
+
const row = db.query(`SELECT value FROM ${META_TABLE} WHERE key=?`, [key])[0];
|
|
135
135
|
return row === undefined ? undefined : row.value;
|
|
136
136
|
}
|
|
137
137
|
export function setMeta(db, key, value) {
|
|
138
|
-
db.exec(
|
|
138
|
+
db.exec(`INSERT OR REPLACE INTO ${META_TABLE}(key,value)VALUES(?,?)`, [
|
|
139
139
|
key,
|
|
140
140
|
value,
|
|
141
141
|
]);
|
package/dist/window.d.ts
CHANGED
package/dist/window.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const PENDING_EVICTIONS = '_syncular_window_pending_evict';
|
|
2
|
+
export const WINDOWS_TABLE = '_syncular_windows';
|
|
1
3
|
/**
|
|
2
4
|
* Windowed subscriptions (SPEC.md §4.8).
|
|
3
5
|
*
|
|
@@ -88,12 +90,12 @@ export async function deriveSubId(base, unit) {
|
|
|
88
90
|
/** Live units for a base, ordered by unit value. */
|
|
89
91
|
export function loadWindowUnits(db, baseKey) {
|
|
90
92
|
return db
|
|
91
|
-
.query(
|
|
93
|
+
.query(`SELECT unit,sub_id FROM ${WINDOWS_TABLE} WHERE base=? ORDER BY unit`, [baseKey])
|
|
92
94
|
.map((row) => ({ unit: row.unit, subId: row.sub_id }));
|
|
93
95
|
}
|
|
94
96
|
/** Registry lookup used to emit exact completion changes for a sub id. */
|
|
95
97
|
export function getWindowUnitBySubId(db, subId) {
|
|
96
|
-
const row = db.query(`SELECT base, unit, sub_id FROM
|
|
98
|
+
const row = db.query(`SELECT base, unit, sub_id FROM ${WINDOWS_TABLE}
|
|
97
99
|
WHERE sub_id = ? LIMIT 1`, [subId])[0];
|
|
98
100
|
return row === undefined
|
|
99
101
|
? undefined
|
|
@@ -104,30 +106,31 @@ export function getWindowUnitBySubId(db, subId) {
|
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
export function insertWindowUnit(db, baseKey, unit, subId) {
|
|
107
|
-
db.exec(
|
|
109
|
+
db.exec(`INSERT OR REPLACE INTO ${WINDOWS_TABLE}(base,unit,sub_id)VALUES(?,?,?)`, [baseKey, unit, subId]);
|
|
108
110
|
}
|
|
109
111
|
export function deleteWindowUnit(db, baseKey, unit) {
|
|
110
|
-
db.exec(
|
|
112
|
+
db.exec(`DELETE FROM ${WINDOWS_TABLE} WHERE base=? AND unit=?`, [
|
|
111
113
|
baseKey,
|
|
112
114
|
unit,
|
|
113
115
|
]);
|
|
114
116
|
}
|
|
115
117
|
/** Is a single scope value windowed-in for this base? (the oracle, I3) */
|
|
116
118
|
export function isUnitLive(db, baseKey, unit) {
|
|
117
|
-
return (db.query(
|
|
119
|
+
return (db.query(`SELECT 1 FROM ${WINDOWS_TABLE} WHERE base=? AND unit=? LIMIT 1`, [
|
|
120
|
+
baseKey,
|
|
121
|
+
unit,
|
|
122
|
+
]).length > 0);
|
|
118
123
|
}
|
|
119
124
|
export function savePendingEviction(db, subId, table, effective) {
|
|
120
|
-
db.exec(`INSERT OR REPLACE INTO
|
|
125
|
+
db.exec(`INSERT OR REPLACE INTO ${PENDING_EVICTIONS}(sub_id, tbl, effective_scopes)
|
|
121
126
|
VALUES (?, ?, ?)`, [subId, table, JSON.stringify(effective)]);
|
|
122
127
|
}
|
|
123
128
|
export function deletePendingEviction(db, subId) {
|
|
124
|
-
db.exec(
|
|
125
|
-
subId,
|
|
126
|
-
]);
|
|
129
|
+
db.exec(`DELETE FROM ${PENDING_EVICTIONS} WHERE sub_id = ?`, [subId]);
|
|
127
130
|
}
|
|
128
131
|
export function loadPendingEvictions(db) {
|
|
129
132
|
return db
|
|
130
|
-
.query(
|
|
133
|
+
.query(`SELECT sub_id, tbl, effective_scopes FROM ${PENDING_EVICTIONS}`)
|
|
131
134
|
.map((row) => ({
|
|
132
135
|
subId: row.sub_id,
|
|
133
136
|
table: row.tbl,
|
package/dist/worker-entry.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* RPC-driven and auto-driven sync rounds serialize on one queue because
|
|
14
14
|
* the core owns exactly one loop.
|
|
15
15
|
*/
|
|
16
|
+
import { defineAuthorityReads } from './authority.js';
|
|
16
17
|
import { SyncClient } from './client.js';
|
|
17
18
|
import { withClientDiagnosticsHost, } from './diagnostics.js';
|
|
18
19
|
import { encryptionConfigFromKeyring } from './encryption.js';
|
|
@@ -233,6 +234,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
233
234
|
...(config.encryption !== undefined
|
|
234
235
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
235
236
|
: {}),
|
|
237
|
+
...(config.authorityReads !== undefined
|
|
238
|
+
? { authorityReads: defineAuthorityReads(config.authorityReads) }
|
|
239
|
+
: {}),
|
|
236
240
|
...(config.securityPreflight !== undefined
|
|
237
241
|
? { securityPreflight: config.securityPreflight }
|
|
238
242
|
: {}),
|
|
@@ -342,6 +346,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
342
346
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
343
347
|
},
|
|
344
348
|
query: (sql, params) => requireClient().query(sql, params),
|
|
349
|
+
authoritySnapshot: () => requireClient().authoritySnapshot(),
|
|
345
350
|
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
346
351
|
localRevision: () => requireClient().localRevision,
|
|
347
352
|
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
@@ -420,6 +425,8 @@ export function startSyncWorker(overrides = {}) {
|
|
|
420
425
|
'previousVersionDiscard',
|
|
421
426
|
]);
|
|
422
427
|
async function dispatch(message) {
|
|
428
|
+
if (message.method === 'authoritySnapshot' && message.args.length)
|
|
429
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
|
|
423
430
|
const method = api[message.method];
|
|
424
431
|
const invoke = () => method.apply(api, message.args);
|
|
425
432
|
if (syncChainMethods.has(message.method)) {
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface SyncClientHandleConfig {
|
|
|
83
83
|
readonly headers?: Readonly<Record<string, string>>;
|
|
84
84
|
/** Structured-clone-safe E2EE keyring installed only in the leader worker. */
|
|
85
85
|
readonly encryption?: EncryptionKeyringConfig;
|
|
86
|
+
/** Fixed plain authority reads permitted before security activation. */
|
|
87
|
+
readonly authorityReads?: import('./authority.js').AuthorityReadPolicy;
|
|
86
88
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
87
89
|
readonly securityPreflight?: boolean;
|
|
88
90
|
readonly transportEnabled?: boolean;
|
|
@@ -244,6 +246,7 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
244
246
|
sync(): Promise<SyncSummary>;
|
|
245
247
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
246
248
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
249
|
+
authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
|
|
247
250
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
248
251
|
localRevision(): Promise<LocalRevision>;
|
|
249
252
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
package/dist/worker-host.js
CHANGED
|
@@ -332,6 +332,11 @@ export class SyncClientHandle {
|
|
|
332
332
|
query(sql, params) {
|
|
333
333
|
return this.#call('query', [sql, params]);
|
|
334
334
|
}
|
|
335
|
+
authoritySnapshot() {
|
|
336
|
+
if (arguments.length)
|
|
337
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
|
|
338
|
+
return this.#call('authoritySnapshot', []);
|
|
339
|
+
}
|
|
335
340
|
querySnapshot(spec) {
|
|
336
341
|
return this.#call('querySnapshot', [spec]);
|
|
337
342
|
}
|
|
@@ -569,6 +574,9 @@ function buildInitConfig(config, headers = config.headers) {
|
|
|
569
574
|
...(config.transportEnabled !== undefined
|
|
570
575
|
? { transportEnabled: config.transportEnabled }
|
|
571
576
|
: {}),
|
|
577
|
+
...(config.authorityReads !== undefined
|
|
578
|
+
? { authorityReads: config.authorityReads.declarations }
|
|
579
|
+
: {}),
|
|
572
580
|
...(config.securityPreflight !== undefined
|
|
573
581
|
? { securityPreflight: config.securityPreflight }
|
|
574
582
|
: {}),
|
|
@@ -77,6 +77,7 @@ export interface WorkerInitConfig {
|
|
|
77
77
|
/** Portable raw keyring installed inside the worker-owned client core. */
|
|
78
78
|
readonly encryption?: EncryptionKeyringConfig;
|
|
79
79
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
80
|
+
readonly authorityReads?: readonly import('./authority.js').AuthorityReadDeclaration[];
|
|
80
81
|
readonly securityPreflight?: boolean;
|
|
81
82
|
readonly transportEnabled?: boolean;
|
|
82
83
|
readonly clientId?: string;
|
|
@@ -138,6 +139,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
|
|
|
138
139
|
sync(): Promise<SyncSummary>;
|
|
139
140
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
140
141
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
142
|
+
authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
|
|
141
143
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
142
144
|
localRevision(): LocalRevision;
|
|
143
145
|
pendingCommits(): OutboxCommit[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.15",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.15"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.15",
|
|
96
96
|
"playwright": "1.63.0",
|
|
97
97
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hasUniqueIndex } from './schema';
|
|
2
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox';
|
|
3
|
+
import { RETAINED_ROWS, type OverlayScope } from './failed-overlay';
|
|
2
4
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
|
|
3
5
|
/**
|
|
4
6
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
} from './schema';
|
|
29
31
|
|
|
30
32
|
/** Lets the client wrap the physical write in its revision transaction. */
|
|
31
|
-
export type ApplyTransaction =
|
|
33
|
+
export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
|
|
32
34
|
|
|
33
35
|
function upsertSql(table: CompiledClientTable): string {
|
|
34
36
|
const names = [
|
|
@@ -96,8 +98,7 @@ export async function applyCommitFrame(
|
|
|
96
98
|
schema: CompiledClientSchema,
|
|
97
99
|
frame: CommitFrame,
|
|
98
100
|
encryption?: EncryptionConfig,
|
|
99
|
-
transaction: (fn: () =>
|
|
100
|
-
db.transaction(fn),
|
|
101
|
+
transaction: (fn: () => OverlayScope) => unknown = (fn) => db.transaction(fn),
|
|
101
102
|
): Promise<void> {
|
|
102
103
|
type Resolved =
|
|
103
104
|
| { op: 'delete'; table: CompiledClientTable; rowId: string }
|
|
@@ -142,18 +143,18 @@ export async function applyCommitFrame(
|
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
145
|
transaction(() => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
restoreFailedBases(db, schema,
|
|
146
|
+
const scope = resolved.map((change, index) =>
|
|
147
|
+
JSON.stringify(
|
|
148
|
+
hasUniqueIndex(change.table)
|
|
149
|
+
? [change.table.name]
|
|
150
|
+
: [change.table.name, frame.changes[index]!.rowId],
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
restoreFailedBases(db, schema, scope);
|
|
153
154
|
for (const [index, change] of resolved.entries()) {
|
|
154
155
|
const rowId = frame.changes[index]!.rowId;
|
|
155
156
|
db.exec(
|
|
156
|
-
|
|
157
|
+
`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`,
|
|
157
158
|
[frame.commitSeq, change.table.name, rowId],
|
|
158
159
|
);
|
|
159
160
|
db.exec(
|
|
@@ -166,7 +167,7 @@ export async function applyCommitFrame(
|
|
|
166
167
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
|
-
return
|
|
170
|
+
return scope;
|
|
170
171
|
});
|
|
171
172
|
}
|
|
172
173
|
|
|
@@ -238,6 +239,41 @@ export function localScopePredicate(
|
|
|
238
239
|
return { sql, params };
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
function scopedRows(
|
|
243
|
+
db: ClientDatabase,
|
|
244
|
+
table: CompiledClientTable,
|
|
245
|
+
scope: ReturnType<typeof localScopePredicate>,
|
|
246
|
+
) {
|
|
247
|
+
return scope
|
|
248
|
+
? db.query(
|
|
249
|
+
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`,
|
|
250
|
+
scope.params,
|
|
251
|
+
)
|
|
252
|
+
: [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function bootstrapScope(
|
|
256
|
+
db: ClientDatabase,
|
|
257
|
+
table: CompiledClientTable,
|
|
258
|
+
effective: ScopeMap,
|
|
259
|
+
): string[] {
|
|
260
|
+
const rows = scopedRows(db, table, localScopePredicate(table, effective));
|
|
261
|
+
rows.push(
|
|
262
|
+
...db
|
|
263
|
+
.query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
|
|
264
|
+
table.name,
|
|
265
|
+
])
|
|
266
|
+
.filter((row) =>
|
|
267
|
+
[row.base, row.intent].some(
|
|
268
|
+
(json) =>
|
|
269
|
+
json !== null &&
|
|
270
|
+
matchesLocalScopes(table, effective, JSON.parse(json as string)),
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
export function deleteScopedRows(
|
|
242
278
|
db: ClientDatabase,
|
|
243
279
|
table: CompiledClientTable,
|
|
@@ -246,10 +282,7 @@ export function deleteScopedRows(
|
|
|
246
282
|
const scope = localScopePredicate(table, effective);
|
|
247
283
|
if (!scope) return;
|
|
248
284
|
const { sql, params } = scope;
|
|
249
|
-
for (const row of db
|
|
250
|
-
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
|
|
251
|
-
params,
|
|
252
|
-
)) {
|
|
285
|
+
for (const row of scopedRows(db, table, scope)) {
|
|
253
286
|
retainedBaseWrite(db, table, String(row.id));
|
|
254
287
|
}
|
|
255
288
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
@@ -451,8 +484,31 @@ export async function applySqliteSegment(
|
|
|
451
484
|
predicates.push(`${primaryKey} <= ?`);
|
|
452
485
|
params.push(boundary);
|
|
453
486
|
}
|
|
487
|
+
const replay =
|
|
488
|
+
countOutbox(db) ||
|
|
489
|
+
db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
|
|
490
|
+
table.name,
|
|
491
|
+
]).length;
|
|
492
|
+
const rows = replay
|
|
493
|
+
? db.query(
|
|
494
|
+
`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
|
|
495
|
+
params,
|
|
496
|
+
)
|
|
497
|
+
: [];
|
|
454
498
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
455
|
-
|
|
499
|
+
const scope = !replay
|
|
500
|
+
? []
|
|
501
|
+
: hasUniqueIndex(table)
|
|
502
|
+
? [JSON.stringify([table.name])]
|
|
503
|
+
: [
|
|
504
|
+
...(after === undefined && options.clearFirst
|
|
505
|
+
? bootstrapScope(db, table, options.effective)
|
|
506
|
+
: []),
|
|
507
|
+
...rows.map((row) =>
|
|
508
|
+
JSON.stringify([table.name, String(row[table.primaryKey])]),
|
|
509
|
+
),
|
|
510
|
+
];
|
|
511
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
456
512
|
if (after === undefined && options.clearFirst)
|
|
457
513
|
deleteScopedRows(db, table, options.effective);
|
|
458
514
|
db.exec(
|
|
@@ -460,23 +516,22 @@ export async function applySqliteSegment(
|
|
|
460
516
|
params,
|
|
461
517
|
);
|
|
462
518
|
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
519
|
+
if (protectedRows)
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
retainedBaseWrite(
|
|
522
|
+
db,
|
|
523
|
+
table,
|
|
524
|
+
String(row[table.primaryKey]),
|
|
525
|
+
Object.fromEntries(
|
|
526
|
+
table.columns.map((column) => [
|
|
527
|
+
column.name,
|
|
528
|
+
rowValueToJson(row[column.name] as RowValue),
|
|
529
|
+
]),
|
|
530
|
+
),
|
|
531
|
+
Number(row._syncular_version),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return scope;
|
|
480
535
|
});
|
|
481
536
|
options.onProgress?.(applied);
|
|
482
537
|
await yieldToHost();
|
|
@@ -537,7 +592,20 @@ export async function applyRowsSegment(
|
|
|
537
592
|
first = false;
|
|
538
593
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
539
594
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
540
|
-
const
|
|
595
|
+
const scope: OverlayScope = hasUniqueIndex(table)
|
|
596
|
+
? [JSON.stringify([table.name])]
|
|
597
|
+
: [
|
|
598
|
+
...(clearThisBlock
|
|
599
|
+
? bootstrapScope(db, table, options.effective)
|
|
600
|
+
: []),
|
|
601
|
+
...rows.map((row) =>
|
|
602
|
+
JSON.stringify([
|
|
603
|
+
table.name,
|
|
604
|
+
String(row.values[table.primaryKeyIndex]),
|
|
605
|
+
]),
|
|
606
|
+
),
|
|
607
|
+
];
|
|
608
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
541
609
|
if (clearThisBlock) {
|
|
542
610
|
deleteScopedRows(db, table, options.effective);
|
|
543
611
|
}
|
|
@@ -546,6 +614,7 @@ export async function applyRowsSegment(
|
|
|
546
614
|
applied += 1;
|
|
547
615
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
548
616
|
}
|
|
617
|
+
return scope;
|
|
549
618
|
});
|
|
550
619
|
options.onProgress?.(applied);
|
|
551
620
|
await yieldToHost();
|