@syncular/client 0.30.14 → 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/authority.d.ts +43 -0
- package/dist/authority.js +257 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +18 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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/authority.ts +414 -0
- package/src/client.ts +24 -27
- package/src/index.ts +6 -0
- package/src/worker-entry.ts +10 -0
- package/src/worker-host.ts +14 -0
- package/src/worker-protocol.ts +2 -0
package/README.md
CHANGED
|
@@ -818,3 +818,13 @@ See [windowing](https://syncular.dev/concepts-windowing/) and
|
|
|
818
818
|
Custom database adapters implementing `withSqliteImage` must return a promise and
|
|
819
819
|
await their callback before detaching the image. The callback can await between
|
|
820
820
|
committed import chunks; no write transaction spans that await.
|
|
821
|
+
|
|
822
|
+
## Preflight authority reads
|
|
823
|
+
|
|
824
|
+
Pass `authorityReads: defineAuthorityReads([{ table, columns, scopes }])` at
|
|
825
|
+
creation to enable the zero-argument `authoritySnapshot()`. It reads approved
|
|
826
|
+
plain authority columns and accepted bases with revision and persisted scope
|
|
827
|
+
coverage from one SQLite snapshot, including during security preflight.
|
|
828
|
+
Unaccepted local intent remains separate. The read changes no keys, transport,
|
|
829
|
+
subscriptions or lifecycle. See the [authority contract](../../docs/SPEC.md)
|
|
830
|
+
and [native configuration](../tauri/README.md#authority-evidence-before-activation).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClientDatabase, SqlRow } from './database.js';
|
|
2
|
+
import type { CompiledClientSchema } from './schema.js';
|
|
3
|
+
/** Frozen at client creation. Scope selectors use the schema's variables. */
|
|
4
|
+
export interface AuthorityReadDeclaration {
|
|
5
|
+
readonly table: string;
|
|
6
|
+
readonly columns: readonly string[];
|
|
7
|
+
readonly scopes: Readonly<Record<string, readonly string[]>>;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthoritySnapshot {
|
|
10
|
+
readonly revision: bigint;
|
|
11
|
+
readonly complete: boolean;
|
|
12
|
+
readonly tables: readonly {
|
|
13
|
+
readonly table: string;
|
|
14
|
+
readonly rows: readonly {
|
|
15
|
+
readonly values: SqlRow;
|
|
16
|
+
readonly version: number;
|
|
17
|
+
readonly hasLocalIntent: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
/** Includes local-only creations; no intended values are disclosed. */
|
|
20
|
+
readonly localIntentRowIds: readonly string[];
|
|
21
|
+
readonly coverage: 'complete' | 'pending' | 'missing';
|
|
22
|
+
readonly scopes: AuthorityReadDeclaration['scopes'];
|
|
23
|
+
readonly persisted: readonly {
|
|
24
|
+
readonly requestedScopes: AuthorityReadDeclaration['scopes'];
|
|
25
|
+
readonly status: string;
|
|
26
|
+
readonly cursor: number;
|
|
27
|
+
readonly effectiveScopes: AuthorityReadDeclaration['scopes'] | null;
|
|
28
|
+
readonly complete: boolean;
|
|
29
|
+
}[];
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
/** Opt-in policy module; ordinary clients ship no authority reader. */
|
|
33
|
+
export declare class AuthorityReadPolicy {
|
|
34
|
+
#private;
|
|
35
|
+
get declarations(): readonly AuthorityReadDeclaration[];
|
|
36
|
+
constructor(declarations: readonly AuthorityReadDeclaration[]);
|
|
37
|
+
/** @internal */
|
|
38
|
+
validate(schema: CompiledClientSchema): void;
|
|
39
|
+
/** @internal */
|
|
40
|
+
snapshot(db: ClientDatabase, schema: CompiledClientSchema): AuthoritySnapshot;
|
|
41
|
+
}
|
|
42
|
+
/** Declare authority tables, plain columns and scope selectors at creation. */
|
|
43
|
+
export declare function defineAuthorityReads(declarations: readonly AuthorityReadDeclaration[]): AuthorityReadPolicy;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { listOutbox, listOutboxBeforeImages } from './outbox.js';
|
|
3
|
+
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
4
|
+
import { getLocalRevision, getMeta, loadSubscriptions } from './state.js';
|
|
5
|
+
import { fromSqlValue, quoteIdent, SYNC_VERSION_COLUMN } from './schema.js';
|
|
6
|
+
function validateAuthorityReads(schema, declarations) {
|
|
7
|
+
if (!Array.isArray(declarations))
|
|
8
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads must be a list');
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
for (const read of declarations) {
|
|
11
|
+
if (!read ||
|
|
12
|
+
typeof read !== 'object' ||
|
|
13
|
+
!Array.isArray(read.columns) ||
|
|
14
|
+
!read.scopes ||
|
|
15
|
+
typeof read.scopes !== 'object' ||
|
|
16
|
+
Array.isArray(read.scopes))
|
|
17
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'invalid authority declaration');
|
|
18
|
+
const table = schema.tables.get(read.table);
|
|
19
|
+
const plain = (name) => table?.columns.some((c) => c.name === name &&
|
|
20
|
+
name !== 'authority_version' &&
|
|
21
|
+
!name.startsWith('_sync') &&
|
|
22
|
+
!c.encrypted &&
|
|
23
|
+
['string', 'integer', 'float', 'boolean', 'json'].includes(c.type));
|
|
24
|
+
if (Object.keys(read).sort().join() !== 'columns,scopes,table' ||
|
|
25
|
+
!table ||
|
|
26
|
+
seen.has(read.table) ||
|
|
27
|
+
!read.columns.length ||
|
|
28
|
+
new Set(read.columns).size !== read.columns.length ||
|
|
29
|
+
!read.columns.includes(table.primaryKey) ||
|
|
30
|
+
!read.columns.every(plain) ||
|
|
31
|
+
!Object.keys(read.scopes).length ||
|
|
32
|
+
!Object.entries(read.scopes).every(([variable, values]) => read.columns.includes(table.scopeColumnByVariable.get(variable) ?? '') &&
|
|
33
|
+
plain(table.scopeColumnByVariable.get(variable) ?? '') &&
|
|
34
|
+
Array.isArray(values) &&
|
|
35
|
+
values.length > 0 &&
|
|
36
|
+
values.every((v) => typeof v === 'string' && v.length > 0 && v !== '*'))) {
|
|
37
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authority reads require declared tables, plain columns, a primary key and nonempty plain scope selectors');
|
|
38
|
+
}
|
|
39
|
+
Object.freeze(read.columns);
|
|
40
|
+
for (const values of Object.values(read.scopes))
|
|
41
|
+
Object.freeze(values);
|
|
42
|
+
Object.freeze(read.scopes);
|
|
43
|
+
Object.freeze(read);
|
|
44
|
+
seen.add(read.table);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Read accepted bases without restoring or replaying the visible projection. */
|
|
48
|
+
function readAuthoritySnapshot(db, schema, declarations) {
|
|
49
|
+
if (!declarations.length)
|
|
50
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'no authority reads were declared at client creation');
|
|
51
|
+
return db.transaction(() => {
|
|
52
|
+
const raw = getMeta(db, 'localRevision');
|
|
53
|
+
if (raw === undefined ||
|
|
54
|
+
!/^(0|[1-9][0-9]*)$/.test(raw) ||
|
|
55
|
+
BigInt(raw) > 18446744073709551615n)
|
|
56
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority revision is missing or invalid');
|
|
57
|
+
const revision = getLocalRevision(db);
|
|
58
|
+
const subscriptions = loadSubscriptions(db);
|
|
59
|
+
const tables = declarations.map((read) => {
|
|
60
|
+
const table = schema.tables.get(read.table);
|
|
61
|
+
const columns = [
|
|
62
|
+
...new Set([
|
|
63
|
+
...read.columns,
|
|
64
|
+
...Object.keys(read.scopes).map((v) => table.scopeColumnByVariable.get(v)),
|
|
65
|
+
]),
|
|
66
|
+
];
|
|
67
|
+
const scalar = (column, value) => {
|
|
68
|
+
if (value === null || typeof value === 'object')
|
|
69
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority scalar is missing or invalid');
|
|
70
|
+
return String(fromSqlValue(table.columns[table.columnIndex.get(column)], value));
|
|
71
|
+
};
|
|
72
|
+
const matches = (values) => Object.entries(read.scopes).every(([v, held]) => {
|
|
73
|
+
const column = table.scopeColumnByVariable.get(v);
|
|
74
|
+
return (values[column] !== null &&
|
|
75
|
+
values[column] !== undefined &&
|
|
76
|
+
held.includes(scalar(column, values[column])));
|
|
77
|
+
});
|
|
78
|
+
const bases = new Map();
|
|
79
|
+
const intent = new Set();
|
|
80
|
+
const relevantIntent = new Set();
|
|
81
|
+
// FIFO before-images precede the earliest still-pending intent.
|
|
82
|
+
for (const commit of listOutbox(db)) {
|
|
83
|
+
const images = listOutboxBeforeImages(db, commit.clientCommitId);
|
|
84
|
+
for (const [idx, op] of commit.operations.entries())
|
|
85
|
+
if (op.table === read.table) {
|
|
86
|
+
intent.add(op.rowId);
|
|
87
|
+
if (op.values && matches(op.values))
|
|
88
|
+
relevantIntent.add(op.rowId);
|
|
89
|
+
if (bases.has(op.rowId))
|
|
90
|
+
continue;
|
|
91
|
+
const image = images.find((i) => i.opIndex === idx);
|
|
92
|
+
if (!image ||
|
|
93
|
+
(image.existed &&
|
|
94
|
+
(image.values === undefined || image.syncVersion === undefined)))
|
|
95
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority intent before-image is missing');
|
|
96
|
+
bases.set(op.rowId, {
|
|
97
|
+
values: image.values ?? null,
|
|
98
|
+
version: image.syncVersion ?? -1,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// A retained/ACK base precedes pending edits stacked over that intent.
|
|
103
|
+
const retained = new Set();
|
|
104
|
+
for (const row of db.query(`SELECT id,base,version,intent FROM ${RETAINED_ROWS} WHERE tbl=? ORDER BY commit_seq,at,commit_id,idx`, [read.table])) {
|
|
105
|
+
const id = String(row.id);
|
|
106
|
+
if (row.base !== null && row.version === null)
|
|
107
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority base version is missing');
|
|
108
|
+
intent.add(id);
|
|
109
|
+
if (row.intent !== null && matches(JSON.parse(String(row.intent))))
|
|
110
|
+
relevantIntent.add(id);
|
|
111
|
+
if (!retained.has(id))
|
|
112
|
+
bases.set(id, {
|
|
113
|
+
values: row.base === null ? null : JSON.parse(String(row.base)),
|
|
114
|
+
version: Number(row.version ?? -1),
|
|
115
|
+
});
|
|
116
|
+
retained.add(id);
|
|
117
|
+
}
|
|
118
|
+
const visible = db.query(`SELECT ${columns.map(quoteIdent).join(',')},${SYNC_VERSION_COLUMN} FROM ${quoteIdent(read.table)}`);
|
|
119
|
+
for (const row of visible) {
|
|
120
|
+
const id = scalar(table.primaryKey, row[table.primaryKey]);
|
|
121
|
+
if (!bases.has(id))
|
|
122
|
+
bases.set(id, {
|
|
123
|
+
values: row,
|
|
124
|
+
version: Number(row[SYNC_VERSION_COLUMN]),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const rows = [...bases]
|
|
128
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
129
|
+
.flatMap(([id, base]) => {
|
|
130
|
+
if (!Number.isSafeInteger(base.version) ||
|
|
131
|
+
base.version < -1 ||
|
|
132
|
+
(base.values &&
|
|
133
|
+
base.version >= 0 &&
|
|
134
|
+
!read.columns.every((c) => Object.hasOwn(base.values, c))))
|
|
135
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority base evidence is incomplete');
|
|
136
|
+
if (!base.values || base.version < 0 || !matches(base.values))
|
|
137
|
+
return [];
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
values: Object.fromEntries(read.columns.map((c) => [
|
|
141
|
+
c,
|
|
142
|
+
typeof base.values[c] === 'boolean'
|
|
143
|
+
? base.values[c]
|
|
144
|
+
? 1
|
|
145
|
+
: 0
|
|
146
|
+
: (base.values[c] ?? null),
|
|
147
|
+
])),
|
|
148
|
+
version: base.version,
|
|
149
|
+
hasLocalIntent: intent.has(id),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
});
|
|
153
|
+
const persisted = subscriptions
|
|
154
|
+
.filter((s) => s.table === read.table)
|
|
155
|
+
.map((s) => {
|
|
156
|
+
const effective = s.effectiveScopes;
|
|
157
|
+
const complete = effective !== undefined &&
|
|
158
|
+
Object.keys(effective).length === Object.keys(read.scopes).length &&
|
|
159
|
+
s.status === 'active' &&
|
|
160
|
+
s.cursor >= 0 &&
|
|
161
|
+
s.bootstrapState === undefined &&
|
|
162
|
+
s.params === undefined;
|
|
163
|
+
return {
|
|
164
|
+
requestedScopes: Object.fromEntries(Object.keys(read.scopes).map((v) => [v, s.scopes[v] ?? []])),
|
|
165
|
+
status: s.status,
|
|
166
|
+
cursor: s.cursor,
|
|
167
|
+
effectiveScopes: effective === undefined
|
|
168
|
+
? null
|
|
169
|
+
: Object.fromEntries(Object.keys(read.scopes).map((v) => [
|
|
170
|
+
v,
|
|
171
|
+
effective[v] ?? [],
|
|
172
|
+
])),
|
|
173
|
+
complete,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
const selectors = Object.entries(read.scopes);
|
|
177
|
+
const covered = (at, candidates) => at === selectors.length
|
|
178
|
+
? candidates.length > 0
|
|
179
|
+
: selectors[at][1].every((value) => covered(at + 1, candidates.filter((p) => p.effectiveScopes?.[selectors[at][0]]?.some((held) => held === value || held === '*'))));
|
|
180
|
+
const coverage = covered(0, persisted.filter((p) => p.complete))
|
|
181
|
+
? 'complete'
|
|
182
|
+
: persisted.some((p) => p.status === 'active' &&
|
|
183
|
+
selectors.every(([v, values]) => values.some((value) => p.requestedScopes[v]?.includes(value))))
|
|
184
|
+
? 'pending'
|
|
185
|
+
: 'missing';
|
|
186
|
+
return {
|
|
187
|
+
table: read.table,
|
|
188
|
+
rows,
|
|
189
|
+
localIntentRowIds: [...intent]
|
|
190
|
+
.filter((id) => relevantIntent.has(id) ||
|
|
191
|
+
rows.some((row) => scalar(table.primaryKey, row.values[table.primaryKey]) ===
|
|
192
|
+
id) ||
|
|
193
|
+
visible.some((row) => scalar(table.primaryKey, row[table.primaryKey]) === id &&
|
|
194
|
+
matches(row)))
|
|
195
|
+
.sort(),
|
|
196
|
+
scopes: read.scopes,
|
|
197
|
+
coverage,
|
|
198
|
+
persisted,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
revision,
|
|
203
|
+
complete: tables.every((t) => t.coverage === 'complete'),
|
|
204
|
+
tables,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Opt-in policy module; ordinary clients ship no authority reader. */
|
|
209
|
+
export class AuthorityReadPolicy {
|
|
210
|
+
#declarations;
|
|
211
|
+
get declarations() {
|
|
212
|
+
return this.#declarations;
|
|
213
|
+
}
|
|
214
|
+
constructor(declarations) {
|
|
215
|
+
if (!Array.isArray(declarations))
|
|
216
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads must be a list');
|
|
217
|
+
this.#declarations = structuredClone(declarations);
|
|
218
|
+
for (const read of this.#declarations) {
|
|
219
|
+
if (!read ||
|
|
220
|
+
typeof read !== 'object' ||
|
|
221
|
+
!Array.isArray(read.columns) ||
|
|
222
|
+
!read.scopes ||
|
|
223
|
+
typeof read.scopes !== 'object' ||
|
|
224
|
+
Array.isArray(read.scopes))
|
|
225
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'invalid authority declaration');
|
|
226
|
+
Object.freeze(read.columns);
|
|
227
|
+
for (const values of Object.values(read.scopes))
|
|
228
|
+
Object.freeze(values);
|
|
229
|
+
Object.freeze(read.scopes);
|
|
230
|
+
Object.freeze(read);
|
|
231
|
+
}
|
|
232
|
+
Object.freeze(this.#declarations);
|
|
233
|
+
Object.freeze(this);
|
|
234
|
+
}
|
|
235
|
+
/** @internal */
|
|
236
|
+
validate(schema) {
|
|
237
|
+
validateAuthorityReads(schema, this.declarations);
|
|
238
|
+
Object.freeze(this.declarations);
|
|
239
|
+
Object.freeze(this);
|
|
240
|
+
}
|
|
241
|
+
/** @internal */
|
|
242
|
+
snapshot(db, schema) {
|
|
243
|
+
validateAuthorityReads(schema, this.declarations);
|
|
244
|
+
try {
|
|
245
|
+
return readAuthoritySnapshot(db, schema, this.declarations);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error instanceof SyntaxError)
|
|
249
|
+
throw new ClientSyncError('sync.local_corrupt', 'authority persisted evidence is invalid');
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Declare authority tables, plain columns and scope selectors at creation. */
|
|
255
|
+
export function defineAuthorityReads(declarations) {
|
|
256
|
+
return new AuthorityReadPolicy(declarations);
|
|
257
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority.js';
|
|
1
2
|
import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
2
3
|
/**
|
|
3
4
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
@@ -174,6 +175,7 @@ export interface SyncClientConfig {
|
|
|
174
175
|
* hosts must not materialize key bytes before their preflight has passed.
|
|
175
176
|
*/
|
|
176
177
|
readonly securityPreflight?: boolean;
|
|
178
|
+
readonly authorityReads?: AuthorityReadPolicy;
|
|
177
179
|
/**
|
|
178
180
|
* RFC 0005 previous-version context. A feature flag, default off: when
|
|
179
181
|
* absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
|
|
@@ -326,6 +328,8 @@ export declare class SyncClient {
|
|
|
326
328
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
327
329
|
/** Current durable local observer revision (SPEC §7.5). */
|
|
328
330
|
get localRevision(): LocalRevision;
|
|
331
|
+
/** Read the creation-time plain authority policy, including preflight. */
|
|
332
|
+
authoritySnapshot(): AuthoritySnapshot;
|
|
329
333
|
/**
|
|
330
334
|
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
331
335
|
* Reactive integrations use this instead of composing `query()` and
|
package/dist/client.js
CHANGED
|
@@ -130,6 +130,7 @@ function realtimeErrorCode(error) {
|
|
|
130
130
|
}
|
|
131
131
|
export class SyncClient {
|
|
132
132
|
#config;
|
|
133
|
+
#authorityReads;
|
|
133
134
|
#db;
|
|
134
135
|
#schema;
|
|
135
136
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
@@ -237,6 +238,8 @@ export class SyncClient {
|
|
|
237
238
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
238
239
|
this.#db = config.database;
|
|
239
240
|
this.#schema = compileClientSchema(config.schema);
|
|
241
|
+
this.#authorityReads = config.authorityReads;
|
|
242
|
+
this.#authorityReads?.validate(this.#schema);
|
|
240
243
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
241
244
|
if (realtimePolicy !== 'required' &&
|
|
242
245
|
realtimePolicy !== 'optional' &&
|
|
@@ -541,6 +544,7 @@ export class SyncClient {
|
|
|
541
544
|
});
|
|
542
545
|
}
|
|
543
546
|
async close() {
|
|
547
|
+
this.#started = false;
|
|
544
548
|
this.#emitSyncIntent({ kind: 'none' });
|
|
545
549
|
this.#devtoolsUnregister?.();
|
|
546
550
|
this.#devtoolsUnregister = undefined;
|
|
@@ -548,7 +552,6 @@ export class SyncClient {
|
|
|
548
552
|
this.#abortPendingRound('client closed mid-round');
|
|
549
553
|
await this.#lease?.release();
|
|
550
554
|
this.#lease = undefined;
|
|
551
|
-
this.#started = false;
|
|
552
555
|
this.#progress.clear();
|
|
553
556
|
this.#syncNeededListeners.clear();
|
|
554
557
|
this.#syncIntentListeners.clear();
|
|
@@ -696,6 +699,13 @@ export class SyncClient {
|
|
|
696
699
|
this.#requireStarted();
|
|
697
700
|
return getLocalRevision(this.#db);
|
|
698
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
|
+
}
|
|
699
709
|
/**
|
|
700
710
|
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
701
711
|
* Reactive integrations use this instead of composing `query()` and
|
|
@@ -714,24 +724,13 @@ export class SyncClient {
|
|
|
714
724
|
const missing = [];
|
|
715
725
|
for (const requested of spec.coverage ?? []) {
|
|
716
726
|
const baseKey = windowBaseKey(requested.base);
|
|
717
|
-
const
|
|
718
|
-
entry.unit,
|
|
719
|
-
entry.subId,
|
|
720
|
-
]));
|
|
727
|
+
const state = this.windowState(requested.base);
|
|
721
728
|
for (const unit of new Set(requested.units)) {
|
|
722
|
-
const subId = live.get(unit);
|
|
723
729
|
const ref = { baseKey, unit };
|
|
724
|
-
if (
|
|
730
|
+
if (!state.units.includes(unit))
|
|
725
731
|
missing.push(ref);
|
|
726
|
-
|
|
727
|
-
}
|
|
728
|
-
const sub = getSubscription(this.#db, subId);
|
|
729
|
-
if (sub === undefined ||
|
|
730
|
-
sub.status !== 'active' ||
|
|
731
|
-
sub.cursor < 0 ||
|
|
732
|
-
sub.bootstrapState !== undefined) {
|
|
732
|
+
else if (state.pending.includes(unit))
|
|
733
733
|
pending.push(ref);
|
|
734
|
-
}
|
|
735
734
|
}
|
|
736
735
|
}
|
|
737
736
|
return {
|
|
@@ -1044,7 +1043,7 @@ export class SyncClient {
|
|
|
1044
1043
|
item.id.length === 0 ||
|
|
1045
1044
|
typeof item.table !== 'string' ||
|
|
1046
1045
|
item.table.length === 0) {
|
|
1047
|
-
throw invalidRequest('diagnosticsSnapshot
|
|
1046
|
+
throw invalidRequest('diagnosticsSnapshot needs subscription id and table');
|
|
1048
1047
|
}
|
|
1049
1048
|
const registeredSubscription = subscriptions.get(item.id);
|
|
1050
1049
|
if (registeredSubscription !== undefined &&
|
|
@@ -2154,7 +2153,7 @@ export class SyncClient {
|
|
|
2154
2153
|
};
|
|
2155
2154
|
}
|
|
2156
2155
|
if (this.#schemaFloor !== undefined) {
|
|
2157
|
-
throw invalidRequest('local rebootstrap
|
|
2156
|
+
throw invalidRequest('local rebootstrap needs a schema-floor update');
|
|
2158
2157
|
}
|
|
2159
2158
|
const pending = listOutbox(this.#db);
|
|
2160
2159
|
const resetSubscriptions = loadSubscriptions(this.#db).length;
|
|
@@ -3843,13 +3842,13 @@ export class SyncClient {
|
|
|
3843
3842
|
}
|
|
3844
3843
|
#requireStarted() {
|
|
3845
3844
|
if (!this.#started) {
|
|
3846
|
-
throw invalidRequest('call
|
|
3845
|
+
throw invalidRequest('call start() first');
|
|
3847
3846
|
}
|
|
3848
3847
|
}
|
|
3849
3848
|
#requireActive() {
|
|
3850
3849
|
this.#requireStarted();
|
|
3851
3850
|
if (this.#securityLifecycle === 'preflight') {
|
|
3852
|
-
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, '
|
|
3851
|
+
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'protected reads require activateSecurity');
|
|
3853
3852
|
}
|
|
3854
3853
|
}
|
|
3855
3854
|
}
|
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/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/authority.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type { ClientDatabase, SqlRow, SqlValue } from './database';
|
|
2
|
+
import { ClientSyncError } from './errors';
|
|
3
|
+
import { listOutbox, listOutboxBeforeImages } from './outbox';
|
|
4
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
5
|
+
import { getLocalRevision, getMeta, loadSubscriptions } from './state';
|
|
6
|
+
import { fromSqlValue, quoteIdent, SYNC_VERSION_COLUMN } from './schema';
|
|
7
|
+
import type { CompiledClientSchema, JsonRowValue } from './schema';
|
|
8
|
+
|
|
9
|
+
/** Frozen at client creation. Scope selectors use the schema's variables. */
|
|
10
|
+
export interface AuthorityReadDeclaration {
|
|
11
|
+
readonly table: string;
|
|
12
|
+
readonly columns: readonly string[];
|
|
13
|
+
readonly scopes: Readonly<Record<string, readonly string[]>>;
|
|
14
|
+
}
|
|
15
|
+
export interface AuthoritySnapshot {
|
|
16
|
+
readonly revision: bigint;
|
|
17
|
+
readonly complete: boolean;
|
|
18
|
+
readonly tables: readonly {
|
|
19
|
+
readonly table: string;
|
|
20
|
+
readonly rows: readonly {
|
|
21
|
+
readonly values: SqlRow;
|
|
22
|
+
readonly version: number;
|
|
23
|
+
readonly hasLocalIntent: boolean;
|
|
24
|
+
}[];
|
|
25
|
+
/** Includes local-only creations; no intended values are disclosed. */
|
|
26
|
+
readonly localIntentRowIds: readonly string[];
|
|
27
|
+
readonly coverage: 'complete' | 'pending' | 'missing';
|
|
28
|
+
readonly scopes: AuthorityReadDeclaration['scopes'];
|
|
29
|
+
readonly persisted: readonly {
|
|
30
|
+
readonly requestedScopes: AuthorityReadDeclaration['scopes'];
|
|
31
|
+
readonly status: string;
|
|
32
|
+
readonly cursor: number;
|
|
33
|
+
readonly effectiveScopes: AuthorityReadDeclaration['scopes'] | null;
|
|
34
|
+
readonly complete: boolean;
|
|
35
|
+
}[];
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateAuthorityReads(
|
|
40
|
+
schema: CompiledClientSchema,
|
|
41
|
+
declarations: readonly AuthorityReadDeclaration[],
|
|
42
|
+
): void {
|
|
43
|
+
if (!Array.isArray(declarations))
|
|
44
|
+
throw new ClientSyncError(
|
|
45
|
+
'client.authority_read_forbidden',
|
|
46
|
+
'authorityReads must be a list',
|
|
47
|
+
);
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (const read of declarations) {
|
|
50
|
+
if (
|
|
51
|
+
!read ||
|
|
52
|
+
typeof read !== 'object' ||
|
|
53
|
+
!Array.isArray(read.columns) ||
|
|
54
|
+
!read.scopes ||
|
|
55
|
+
typeof read.scopes !== 'object' ||
|
|
56
|
+
Array.isArray(read.scopes)
|
|
57
|
+
)
|
|
58
|
+
throw new ClientSyncError(
|
|
59
|
+
'client.authority_read_forbidden',
|
|
60
|
+
'invalid authority declaration',
|
|
61
|
+
);
|
|
62
|
+
const table = schema.tables.get(read.table);
|
|
63
|
+
const plain = (name: string) =>
|
|
64
|
+
table?.columns.some(
|
|
65
|
+
(c) =>
|
|
66
|
+
c.name === name &&
|
|
67
|
+
name !== 'authority_version' &&
|
|
68
|
+
!name.startsWith('_sync') &&
|
|
69
|
+
!c.encrypted &&
|
|
70
|
+
['string', 'integer', 'float', 'boolean', 'json'].includes(c.type),
|
|
71
|
+
);
|
|
72
|
+
if (
|
|
73
|
+
Object.keys(read).sort().join() !== 'columns,scopes,table' ||
|
|
74
|
+
!table ||
|
|
75
|
+
seen.has(read.table) ||
|
|
76
|
+
!read.columns.length ||
|
|
77
|
+
new Set(read.columns).size !== read.columns.length ||
|
|
78
|
+
!read.columns.includes(table.primaryKey) ||
|
|
79
|
+
!read.columns.every(plain) ||
|
|
80
|
+
!Object.keys(read.scopes).length ||
|
|
81
|
+
!Object.entries(read.scopes).every(
|
|
82
|
+
([variable, values]) =>
|
|
83
|
+
read.columns.includes(
|
|
84
|
+
table.scopeColumnByVariable.get(variable) ?? '',
|
|
85
|
+
) &&
|
|
86
|
+
plain(table.scopeColumnByVariable.get(variable) ?? '') &&
|
|
87
|
+
Array.isArray(values) &&
|
|
88
|
+
values.length > 0 &&
|
|
89
|
+
values.every(
|
|
90
|
+
(v) => typeof v === 'string' && v.length > 0 && v !== '*',
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
throw new ClientSyncError(
|
|
95
|
+
'client.authority_read_forbidden',
|
|
96
|
+
'authority reads require declared tables, plain columns, a primary key and nonempty plain scope selectors',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
Object.freeze(read.columns);
|
|
100
|
+
for (const values of Object.values(read.scopes)) Object.freeze(values);
|
|
101
|
+
Object.freeze(read.scopes);
|
|
102
|
+
Object.freeze(read);
|
|
103
|
+
seen.add(read.table);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read accepted bases without restoring or replaying the visible projection. */
|
|
108
|
+
function readAuthoritySnapshot(
|
|
109
|
+
db: ClientDatabase,
|
|
110
|
+
schema: CompiledClientSchema,
|
|
111
|
+
declarations: readonly AuthorityReadDeclaration[],
|
|
112
|
+
): AuthoritySnapshot {
|
|
113
|
+
if (!declarations.length)
|
|
114
|
+
throw new ClientSyncError(
|
|
115
|
+
'client.authority_read_forbidden',
|
|
116
|
+
'no authority reads were declared at client creation',
|
|
117
|
+
);
|
|
118
|
+
return db.transaction(() => {
|
|
119
|
+
const raw = getMeta(db, 'localRevision');
|
|
120
|
+
if (
|
|
121
|
+
raw === undefined ||
|
|
122
|
+
!/^(0|[1-9][0-9]*)$/.test(raw) ||
|
|
123
|
+
BigInt(raw) > 18446744073709551615n
|
|
124
|
+
)
|
|
125
|
+
throw new ClientSyncError(
|
|
126
|
+
'sync.local_corrupt',
|
|
127
|
+
'authority revision is missing or invalid',
|
|
128
|
+
);
|
|
129
|
+
const revision = getLocalRevision(db);
|
|
130
|
+
const subscriptions = loadSubscriptions(db);
|
|
131
|
+
const tables = declarations.map((read) => {
|
|
132
|
+
const table = schema.tables.get(read.table)!;
|
|
133
|
+
const columns = [
|
|
134
|
+
...new Set([
|
|
135
|
+
...read.columns,
|
|
136
|
+
...Object.keys(read.scopes).map((v) =>
|
|
137
|
+
table.scopeColumnByVariable.get(v)!,
|
|
138
|
+
),
|
|
139
|
+
]),
|
|
140
|
+
];
|
|
141
|
+
const scalar = (
|
|
142
|
+
column: string,
|
|
143
|
+
value: SqlValue | JsonRowValue,
|
|
144
|
+
): string => {
|
|
145
|
+
if (value === null || typeof value === 'object')
|
|
146
|
+
throw new ClientSyncError(
|
|
147
|
+
'sync.local_corrupt',
|
|
148
|
+
'authority scalar is missing or invalid',
|
|
149
|
+
);
|
|
150
|
+
return String(
|
|
151
|
+
fromSqlValue(table.columns[table.columnIndex.get(column)!]!, value),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
const matches = (values: Record<string, SqlValue | JsonRowValue>) =>
|
|
155
|
+
Object.entries(read.scopes).every(([v, held]) => {
|
|
156
|
+
const column = table.scopeColumnByVariable.get(v)!;
|
|
157
|
+
return (
|
|
158
|
+
values[column] !== null &&
|
|
159
|
+
values[column] !== undefined &&
|
|
160
|
+
held.includes(scalar(column, values[column]!))
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
const bases = new Map<
|
|
164
|
+
string,
|
|
165
|
+
{
|
|
166
|
+
values: Record<string, JsonRowValue | SqlValue> | null;
|
|
167
|
+
version: number;
|
|
168
|
+
}
|
|
169
|
+
>();
|
|
170
|
+
const intent = new Set<string>();
|
|
171
|
+
const relevantIntent = new Set<string>();
|
|
172
|
+
// FIFO before-images precede the earliest still-pending intent.
|
|
173
|
+
for (const commit of listOutbox(db)) {
|
|
174
|
+
const images = listOutboxBeforeImages(db, commit.clientCommitId);
|
|
175
|
+
for (const [idx, op] of commit.operations.entries())
|
|
176
|
+
if (op.table === read.table) {
|
|
177
|
+
intent.add(op.rowId);
|
|
178
|
+
if (op.values && matches(op.values)) relevantIntent.add(op.rowId);
|
|
179
|
+
if (bases.has(op.rowId)) continue;
|
|
180
|
+
const image = images.find((i) => i.opIndex === idx);
|
|
181
|
+
if (
|
|
182
|
+
!image ||
|
|
183
|
+
(image.existed &&
|
|
184
|
+
(image.values === undefined || image.syncVersion === undefined))
|
|
185
|
+
)
|
|
186
|
+
throw new ClientSyncError(
|
|
187
|
+
'sync.local_corrupt',
|
|
188
|
+
'authority intent before-image is missing',
|
|
189
|
+
);
|
|
190
|
+
bases.set(op.rowId, {
|
|
191
|
+
values: image.values ?? null,
|
|
192
|
+
version: image.syncVersion ?? -1,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// A retained/ACK base precedes pending edits stacked over that intent.
|
|
197
|
+
const retained = new Set<string>();
|
|
198
|
+
for (const row of db.query(
|
|
199
|
+
`SELECT id,base,version,intent FROM ${RETAINED_ROWS} WHERE tbl=? ORDER BY commit_seq,at,commit_id,idx`,
|
|
200
|
+
[read.table],
|
|
201
|
+
)) {
|
|
202
|
+
const id = String(row.id);
|
|
203
|
+
if (row.base !== null && row.version === null)
|
|
204
|
+
throw new ClientSyncError(
|
|
205
|
+
'sync.local_corrupt',
|
|
206
|
+
'authority base version is missing',
|
|
207
|
+
);
|
|
208
|
+
intent.add(id);
|
|
209
|
+
if (row.intent !== null && matches(JSON.parse(String(row.intent))))
|
|
210
|
+
relevantIntent.add(id);
|
|
211
|
+
if (!retained.has(id))
|
|
212
|
+
bases.set(id, {
|
|
213
|
+
values: row.base === null ? null : JSON.parse(String(row.base)),
|
|
214
|
+
version: Number(row.version ?? -1),
|
|
215
|
+
});
|
|
216
|
+
retained.add(id);
|
|
217
|
+
}
|
|
218
|
+
const visible = db.query(
|
|
219
|
+
`SELECT ${columns.map(quoteIdent).join(',')},${SYNC_VERSION_COLUMN} FROM ${quoteIdent(read.table)}`,
|
|
220
|
+
);
|
|
221
|
+
for (const row of visible) {
|
|
222
|
+
const id = scalar(table.primaryKey, row[table.primaryKey]!);
|
|
223
|
+
if (!bases.has(id))
|
|
224
|
+
bases.set(id, {
|
|
225
|
+
values: row,
|
|
226
|
+
version: Number(row[SYNC_VERSION_COLUMN]),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const rows = [...bases]
|
|
230
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
231
|
+
.flatMap(([id, base]) => {
|
|
232
|
+
if (
|
|
233
|
+
!Number.isSafeInteger(base.version) ||
|
|
234
|
+
base.version < -1 ||
|
|
235
|
+
(base.values &&
|
|
236
|
+
base.version >= 0 &&
|
|
237
|
+
!read.columns.every((c) => Object.hasOwn(base.values!, c)))
|
|
238
|
+
)
|
|
239
|
+
throw new ClientSyncError(
|
|
240
|
+
'sync.local_corrupt',
|
|
241
|
+
'authority base evidence is incomplete',
|
|
242
|
+
);
|
|
243
|
+
if (!base.values || base.version < 0 || !matches(base.values))
|
|
244
|
+
return [];
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
values: Object.fromEntries(
|
|
248
|
+
read.columns.map((c) => [
|
|
249
|
+
c,
|
|
250
|
+
typeof base.values![c] === 'boolean'
|
|
251
|
+
? base.values![c]
|
|
252
|
+
? 1
|
|
253
|
+
: 0
|
|
254
|
+
: (base.values![c] ?? null),
|
|
255
|
+
]),
|
|
256
|
+
) as SqlRow,
|
|
257
|
+
version: base.version,
|
|
258
|
+
hasLocalIntent: intent.has(id),
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
});
|
|
262
|
+
const persisted = subscriptions
|
|
263
|
+
.filter((s) => s.table === read.table)
|
|
264
|
+
.map((s) => {
|
|
265
|
+
const effective = s.effectiveScopes;
|
|
266
|
+
const complete =
|
|
267
|
+
effective !== undefined &&
|
|
268
|
+
Object.keys(effective).length === Object.keys(read.scopes).length &&
|
|
269
|
+
s.status === 'active' &&
|
|
270
|
+
s.cursor >= 0 &&
|
|
271
|
+
s.bootstrapState === undefined &&
|
|
272
|
+
s.params === undefined;
|
|
273
|
+
return {
|
|
274
|
+
requestedScopes: Object.fromEntries(
|
|
275
|
+
Object.keys(read.scopes).map((v) => [v, s.scopes[v] ?? []]),
|
|
276
|
+
),
|
|
277
|
+
status: s.status,
|
|
278
|
+
cursor: s.cursor,
|
|
279
|
+
effectiveScopes:
|
|
280
|
+
effective === undefined
|
|
281
|
+
? null
|
|
282
|
+
: Object.fromEntries(
|
|
283
|
+
Object.keys(read.scopes).map((v) => [
|
|
284
|
+
v,
|
|
285
|
+
effective[v] ?? [],
|
|
286
|
+
]),
|
|
287
|
+
),
|
|
288
|
+
complete,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const selectors = Object.entries(read.scopes);
|
|
292
|
+
const covered = (at: number, candidates: typeof persisted): boolean =>
|
|
293
|
+
at === selectors.length
|
|
294
|
+
? candidates.length > 0
|
|
295
|
+
: selectors[at]![1].every((value) =>
|
|
296
|
+
covered(
|
|
297
|
+
at + 1,
|
|
298
|
+
candidates.filter((p) =>
|
|
299
|
+
p.effectiveScopes?.[selectors[at]![0]]?.some(
|
|
300
|
+
(held) => held === value || held === '*',
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
const coverage = covered(
|
|
306
|
+
0,
|
|
307
|
+
persisted.filter((p) => p.complete),
|
|
308
|
+
)
|
|
309
|
+
? 'complete'
|
|
310
|
+
: persisted.some(
|
|
311
|
+
(p) =>
|
|
312
|
+
p.status === 'active' &&
|
|
313
|
+
selectors.every(([v, values]) =>
|
|
314
|
+
values.some((value) => p.requestedScopes[v]?.includes(value)),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
? 'pending'
|
|
318
|
+
: 'missing';
|
|
319
|
+
return {
|
|
320
|
+
table: read.table,
|
|
321
|
+
rows,
|
|
322
|
+
localIntentRowIds: [...intent]
|
|
323
|
+
.filter(
|
|
324
|
+
(id) =>
|
|
325
|
+
relevantIntent.has(id) ||
|
|
326
|
+
rows.some(
|
|
327
|
+
(row) =>
|
|
328
|
+
scalar(table.primaryKey, row.values[table.primaryKey]!) ===
|
|
329
|
+
id,
|
|
330
|
+
) ||
|
|
331
|
+
visible.some(
|
|
332
|
+
(row) =>
|
|
333
|
+
scalar(table.primaryKey, row[table.primaryKey]!) === id &&
|
|
334
|
+
matches(row),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
.sort(),
|
|
338
|
+
scopes: read.scopes,
|
|
339
|
+
coverage,
|
|
340
|
+
persisted,
|
|
341
|
+
} as AuthoritySnapshot['tables'][number];
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
revision,
|
|
345
|
+
complete: tables.every((t) => t.coverage === 'complete'),
|
|
346
|
+
tables,
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Opt-in policy module; ordinary clients ship no authority reader. */
|
|
352
|
+
export class AuthorityReadPolicy {
|
|
353
|
+
readonly #declarations: readonly AuthorityReadDeclaration[];
|
|
354
|
+
get declarations(): readonly AuthorityReadDeclaration[] {
|
|
355
|
+
return this.#declarations;
|
|
356
|
+
}
|
|
357
|
+
constructor(declarations: readonly AuthorityReadDeclaration[]) {
|
|
358
|
+
if (!Array.isArray(declarations))
|
|
359
|
+
throw new ClientSyncError(
|
|
360
|
+
'client.authority_read_forbidden',
|
|
361
|
+
'authorityReads must be a list',
|
|
362
|
+
);
|
|
363
|
+
this.#declarations = structuredClone(declarations);
|
|
364
|
+
for (const read of this.#declarations) {
|
|
365
|
+
if (
|
|
366
|
+
!read ||
|
|
367
|
+
typeof read !== 'object' ||
|
|
368
|
+
!Array.isArray(read.columns) ||
|
|
369
|
+
!read.scopes ||
|
|
370
|
+
typeof read.scopes !== 'object' ||
|
|
371
|
+
Array.isArray(read.scopes)
|
|
372
|
+
)
|
|
373
|
+
throw new ClientSyncError(
|
|
374
|
+
'client.authority_read_forbidden',
|
|
375
|
+
'invalid authority declaration',
|
|
376
|
+
);
|
|
377
|
+
Object.freeze(read.columns);
|
|
378
|
+
for (const values of Object.values(read.scopes)) Object.freeze(values);
|
|
379
|
+
Object.freeze(read.scopes);
|
|
380
|
+
Object.freeze(read);
|
|
381
|
+
}
|
|
382
|
+
Object.freeze(this.#declarations);
|
|
383
|
+
Object.freeze(this);
|
|
384
|
+
}
|
|
385
|
+
/** @internal */
|
|
386
|
+
validate(schema: CompiledClientSchema): void {
|
|
387
|
+
validateAuthorityReads(schema, this.declarations);
|
|
388
|
+
Object.freeze(this.declarations);
|
|
389
|
+
Object.freeze(this);
|
|
390
|
+
}
|
|
391
|
+
/** @internal */
|
|
392
|
+
snapshot(
|
|
393
|
+
db: ClientDatabase,
|
|
394
|
+
schema: CompiledClientSchema,
|
|
395
|
+
): AuthoritySnapshot {
|
|
396
|
+
validateAuthorityReads(schema, this.declarations);
|
|
397
|
+
try {
|
|
398
|
+
return readAuthoritySnapshot(db, schema, this.declarations);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error instanceof SyntaxError)
|
|
401
|
+
throw new ClientSyncError(
|
|
402
|
+
'sync.local_corrupt',
|
|
403
|
+
'authority persisted evidence is invalid',
|
|
404
|
+
);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Declare authority tables, plain columns and scope selectors at creation. */
|
|
410
|
+
export function defineAuthorityReads(
|
|
411
|
+
declarations: readonly AuthorityReadDeclaration[],
|
|
412
|
+
): AuthorityReadPolicy {
|
|
413
|
+
return new AuthorityReadPolicy(declarations);
|
|
414
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BLOB_COMMIT_REFS } from './blob';
|
|
2
|
+
import type { AuthorityReadPolicy, AuthoritySnapshot } from './authority';
|
|
2
3
|
import { hasUniqueIndex } from './schema';
|
|
3
4
|
import { OUTCOMES_TABLE } from './outcomes';
|
|
4
5
|
import { OUTBOX_TABLE } from './outbox';
|
|
@@ -424,6 +425,7 @@ export interface SyncClientConfig {
|
|
|
424
425
|
* hosts must not materialize key bytes before their preflight has passed.
|
|
425
426
|
*/
|
|
426
427
|
readonly securityPreflight?: boolean;
|
|
428
|
+
readonly authorityReads?: AuthorityReadPolicy;
|
|
427
429
|
/**
|
|
428
430
|
* RFC 0005 previous-version context. A feature flag, default off: when
|
|
429
431
|
* absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
|
|
@@ -746,6 +748,7 @@ export type ClientSnapshotReader = {
|
|
|
746
748
|
|
|
747
749
|
export class SyncClient {
|
|
748
750
|
readonly #config: SyncClientConfig;
|
|
751
|
+
readonly #authorityReads: AuthorityReadPolicy | undefined;
|
|
749
752
|
readonly #db: ClientDatabase;
|
|
750
753
|
readonly #schema: CompiledClientSchema;
|
|
751
754
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
@@ -860,6 +863,8 @@ export class SyncClient {
|
|
|
860
863
|
this.#retainFailedCommits = config.retainFailedCommits === true;
|
|
861
864
|
this.#db = config.database;
|
|
862
865
|
this.#schema = compileClientSchema(config.schema);
|
|
866
|
+
this.#authorityReads = config.authorityReads;
|
|
867
|
+
this.#authorityReads?.validate(this.#schema);
|
|
863
868
|
const realtimePolicy = config.realtimePolicy ?? 'optional';
|
|
864
869
|
if (
|
|
865
870
|
realtimePolicy !== 'required' &&
|
|
@@ -1232,6 +1237,7 @@ export class SyncClient {
|
|
|
1232
1237
|
}
|
|
1233
1238
|
|
|
1234
1239
|
async close(): Promise<void> {
|
|
1240
|
+
this.#started = false;
|
|
1235
1241
|
this.#emitSyncIntent({ kind: 'none' });
|
|
1236
1242
|
this.#devtoolsUnregister?.();
|
|
1237
1243
|
this.#devtoolsUnregister = undefined;
|
|
@@ -1239,7 +1245,6 @@ export class SyncClient {
|
|
|
1239
1245
|
this.#abortPendingRound('client closed mid-round');
|
|
1240
1246
|
await this.#lease?.release();
|
|
1241
1247
|
this.#lease = undefined;
|
|
1242
|
-
this.#started = false;
|
|
1243
1248
|
this.#progress.clear();
|
|
1244
1249
|
this.#syncNeededListeners.clear();
|
|
1245
1250
|
this.#syncIntentListeners.clear();
|
|
@@ -1398,6 +1403,17 @@ export class SyncClient {
|
|
|
1398
1403
|
return getLocalRevision(this.#db);
|
|
1399
1404
|
}
|
|
1400
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
|
+
|
|
1401
1417
|
/**
|
|
1402
1418
|
* Read rows, window answerability, and revision from one SQLite snapshot.
|
|
1403
1419
|
* Reactive integrations use this instead of composing `query()` and
|
|
@@ -1418,28 +1434,11 @@ export class SyncClient {
|
|
|
1418
1434
|
const missing: WindowUnitRef[] = [];
|
|
1419
1435
|
for (const requested of spec.coverage ?? []) {
|
|
1420
1436
|
const baseKey = windowBaseKey(requested.base);
|
|
1421
|
-
const
|
|
1422
|
-
loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
1423
|
-
entry.unit,
|
|
1424
|
-
entry.subId,
|
|
1425
|
-
]),
|
|
1426
|
-
);
|
|
1437
|
+
const state = this.windowState(requested.base);
|
|
1427
1438
|
for (const unit of new Set(requested.units)) {
|
|
1428
|
-
const subId = live.get(unit);
|
|
1429
1439
|
const ref = { baseKey, unit };
|
|
1430
|
-
if (
|
|
1431
|
-
|
|
1432
|
-
continue;
|
|
1433
|
-
}
|
|
1434
|
-
const sub = getSubscription(this.#db, subId);
|
|
1435
|
-
if (
|
|
1436
|
-
sub === undefined ||
|
|
1437
|
-
sub.status !== 'active' ||
|
|
1438
|
-
sub.cursor < 0 ||
|
|
1439
|
-
sub.bootstrapState !== undefined
|
|
1440
|
-
) {
|
|
1441
|
-
pending.push(ref);
|
|
1442
|
-
}
|
|
1440
|
+
if (!state.units.includes(unit)) missing.push(ref);
|
|
1441
|
+
else if (state.pending.includes(unit)) pending.push(ref);
|
|
1443
1442
|
}
|
|
1444
1443
|
}
|
|
1445
1444
|
return {
|
|
@@ -1817,7 +1816,7 @@ export class SyncClient {
|
|
|
1817
1816
|
item.table.length === 0
|
|
1818
1817
|
) {
|
|
1819
1818
|
throw invalidRequest(
|
|
1820
|
-
'diagnosticsSnapshot
|
|
1819
|
+
'diagnosticsSnapshot needs subscription id and table',
|
|
1821
1820
|
);
|
|
1822
1821
|
}
|
|
1823
1822
|
const registeredSubscription = subscriptions.get(item.id);
|
|
@@ -3170,9 +3169,7 @@ export class SyncClient {
|
|
|
3170
3169
|
};
|
|
3171
3170
|
}
|
|
3172
3171
|
if (this.#schemaFloor !== undefined) {
|
|
3173
|
-
throw invalidRequest(
|
|
3174
|
-
'local rebootstrap cannot bypass an active schema-floor stop; update the application first',
|
|
3175
|
-
);
|
|
3172
|
+
throw invalidRequest('local rebootstrap needs a schema-floor update');
|
|
3176
3173
|
}
|
|
3177
3174
|
|
|
3178
3175
|
const pending = listOutbox(this.#db);
|
|
@@ -5331,7 +5328,7 @@ export class SyncClient {
|
|
|
5331
5328
|
|
|
5332
5329
|
#requireStarted(): void {
|
|
5333
5330
|
if (!this.#started) {
|
|
5334
|
-
throw invalidRequest('call
|
|
5331
|
+
throw invalidRequest('call start() first');
|
|
5335
5332
|
}
|
|
5336
5333
|
}
|
|
5337
5334
|
|
|
@@ -5340,7 +5337,7 @@ export class SyncClient {
|
|
|
5340
5337
|
if (this.#securityLifecycle === 'preflight') {
|
|
5341
5338
|
throw new ClientSyncError(
|
|
5342
5339
|
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
5343
|
-
'
|
|
5340
|
+
'protected reads require activateSecurity',
|
|
5344
5341
|
);
|
|
5345
5342
|
}
|
|
5346
5343
|
}
|
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';
|
package/src/worker-entry.ts
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';
|
|
16
17
|
import { SyncClient } from './client';
|
|
17
18
|
import type { ClientDatabase } from './database';
|
|
18
19
|
import {
|
|
@@ -323,6 +324,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
323
324
|
...(config.encryption !== undefined
|
|
324
325
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
325
326
|
: {}),
|
|
327
|
+
...(config.authorityReads !== undefined
|
|
328
|
+
? { authorityReads: defineAuthorityReads(config.authorityReads) }
|
|
329
|
+
: {}),
|
|
326
330
|
...(config.securityPreflight !== undefined
|
|
327
331
|
? { securityPreflight: config.securityPreflight }
|
|
328
332
|
: {}),
|
|
@@ -441,6 +445,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
441
445
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
442
446
|
},
|
|
443
447
|
query: (sql, params) => requireClient().query(sql, params),
|
|
448
|
+
authoritySnapshot: () => requireClient().authoritySnapshot(),
|
|
444
449
|
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
445
450
|
localRevision: () => requireClient().localRevision,
|
|
446
451
|
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
@@ -520,6 +525,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
520
525
|
]);
|
|
521
526
|
|
|
522
527
|
async function dispatch(message: WorkerCallMessage): Promise<unknown> {
|
|
528
|
+
if (message.method === 'authoritySnapshot' && message.args.length)
|
|
529
|
+
throw new ClientSyncError(
|
|
530
|
+
'client.authority_read_forbidden',
|
|
531
|
+
'authoritySnapshot accepts no arguments',
|
|
532
|
+
);
|
|
523
533
|
const method = api[message.method] as (...args: unknown[]) => unknown;
|
|
524
534
|
const invoke = () => method.apply(api, message.args as unknown[]);
|
|
525
535
|
if (syncChainMethods.has(message.method)) {
|
package/src/worker-host.ts
CHANGED
|
@@ -192,6 +192,8 @@ export interface SyncClientHandleConfig {
|
|
|
192
192
|
readonly headers?: Readonly<Record<string, string>>;
|
|
193
193
|
/** Structured-clone-safe E2EE keyring installed only in the leader worker. */
|
|
194
194
|
readonly encryption?: EncryptionKeyringConfig;
|
|
195
|
+
/** Fixed plain authority reads permitted before security activation. */
|
|
196
|
+
readonly authorityReads?: import('./authority').AuthorityReadPolicy;
|
|
195
197
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
196
198
|
readonly securityPreflight?: boolean;
|
|
197
199
|
readonly transportEnabled?: boolean;
|
|
@@ -656,6 +658,15 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
656
658
|
return this.#call('query', [sql, params]);
|
|
657
659
|
}
|
|
658
660
|
|
|
661
|
+
authoritySnapshot(): Promise<import('./authority').AuthoritySnapshot> {
|
|
662
|
+
if (arguments.length)
|
|
663
|
+
throw new ClientSyncError(
|
|
664
|
+
'client.authority_read_forbidden',
|
|
665
|
+
'authoritySnapshot accepts no arguments',
|
|
666
|
+
);
|
|
667
|
+
return this.#call('authoritySnapshot', []);
|
|
668
|
+
}
|
|
669
|
+
|
|
659
670
|
querySnapshot<Row = SqlRow>(
|
|
660
671
|
spec: QueryReadSpec,
|
|
661
672
|
): Promise<QuerySnapshot<Row>> {
|
|
@@ -951,6 +962,9 @@ function buildInitConfig(
|
|
|
951
962
|
...(config.transportEnabled !== undefined
|
|
952
963
|
? { transportEnabled: config.transportEnabled }
|
|
953
964
|
: {}),
|
|
965
|
+
...(config.authorityReads !== undefined
|
|
966
|
+
? { authorityReads: config.authorityReads.declarations }
|
|
967
|
+
: {}),
|
|
954
968
|
...(config.securityPreflight !== undefined
|
|
955
969
|
? { securityPreflight: config.securityPreflight }
|
|
956
970
|
: {}),
|
package/src/worker-protocol.ts
CHANGED
|
@@ -111,6 +111,7 @@ export interface WorkerInitConfig {
|
|
|
111
111
|
/** Portable raw keyring installed inside the worker-owned client core. */
|
|
112
112
|
readonly encryption?: EncryptionKeyringConfig;
|
|
113
113
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
114
|
+
readonly authorityReads?: readonly import('./authority').AuthorityReadDeclaration[];
|
|
114
115
|
readonly securityPreflight?: boolean;
|
|
115
116
|
readonly transportEnabled?: boolean;
|
|
116
117
|
readonly clientId?: string;
|
|
@@ -186,6 +187,7 @@ export interface WorkerApi extends Omit<
|
|
|
186
187
|
sync(): Promise<SyncSummary>;
|
|
187
188
|
syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
|
|
188
189
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
190
|
+
authoritySnapshot(): import('./authority').AuthoritySnapshot;
|
|
189
191
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
190
192
|
localRevision(): LocalRevision;
|
|
191
193
|
pendingCommits(): OutboxCommit[];
|