@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/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).
|
package/dist/apply.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OverlayScope } from './failed-overlay.js';
|
|
1
2
|
/**
|
|
2
3
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
3
4
|
* (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
|
|
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
|
|
|
8
9
|
import type { EncryptionConfig } from './encryption.js';
|
|
9
10
|
import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
|
|
10
11
|
/** Lets the client wrap the physical write in its revision transaction. */
|
|
11
|
-
export type ApplyTransaction =
|
|
12
|
+
export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
|
|
12
13
|
export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
|
|
13
14
|
export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
|
|
14
15
|
/**
|
|
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
|
|
|
23
24
|
* (`client.decrypt_failed`) aborts before any local write, so the commit
|
|
24
25
|
* never half-applies.
|
|
25
26
|
*/
|
|
26
|
-
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () =>
|
|
27
|
+
export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
|
|
27
28
|
/**
|
|
28
29
|
* §5.2: the segment's column table must match the generated schema for
|
|
29
30
|
* (table, schemaVersion) — order, names, types, nullability. A mismatch is
|
package/dist/apply.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hasUniqueIndex } from './schema.js';
|
|
2
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
|
|
1
3
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
2
4
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
|
|
3
5
|
/**
|
|
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
transaction(() => {
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
restoreFailedBases(db, schema,
|
|
81
|
+
const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
|
|
82
|
+
? [change.table.name]
|
|
83
|
+
: [change.table.name, frame.changes[index].rowId]));
|
|
84
|
+
restoreFailedBases(db, schema, scope);
|
|
83
85
|
for (const [index, change] of resolved.entries()) {
|
|
84
86
|
const rowId = frame.changes[index].rowId;
|
|
85
|
-
db.exec(
|
|
87
|
+
db.exec(`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')=?)`, [frame.commitSeq, change.table.name, rowId]);
|
|
86
88
|
db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
|
|
87
89
|
if (change.op === 'delete') {
|
|
88
90
|
deleteLocalRow(db, change.table, change.rowId);
|
|
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
|
|
|
91
93
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
|
-
return
|
|
96
|
+
return scope;
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
/**
|
|
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
|
|
|
147
149
|
.join(' AND ');
|
|
148
150
|
return { sql, params };
|
|
149
151
|
}
|
|
152
|
+
function scopedRows(db, table, scope) {
|
|
153
|
+
return scope
|
|
154
|
+
? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
|
|
155
|
+
: [];
|
|
156
|
+
}
|
|
157
|
+
function bootstrapScope(db, table, effective) {
|
|
158
|
+
const rows = scopedRows(db, table, localScopePredicate(table, effective));
|
|
159
|
+
rows.push(...db
|
|
160
|
+
.query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
|
|
161
|
+
table.name,
|
|
162
|
+
])
|
|
163
|
+
.filter((row) => [row.base, row.intent].some((json) => json !== null &&
|
|
164
|
+
matchesLocalScopes(table, effective, JSON.parse(json)))));
|
|
165
|
+
return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
|
|
166
|
+
}
|
|
150
167
|
export function deleteScopedRows(db, table, effective) {
|
|
151
168
|
const scope = localScopePredicate(table, effective);
|
|
152
169
|
if (!scope)
|
|
153
170
|
return;
|
|
154
171
|
const { sql, params } = scope;
|
|
155
|
-
for (const row of db
|
|
172
|
+
for (const row of scopedRows(db, table, scope)) {
|
|
156
173
|
retainedBaseWrite(db, table, String(row.id));
|
|
157
174
|
}
|
|
158
175
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
|
|
|
287
304
|
predicates.push(`${primaryKey} <= ?`);
|
|
288
305
|
params.push(boundary);
|
|
289
306
|
}
|
|
307
|
+
const replay = countOutbox(db) ||
|
|
308
|
+
db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
|
|
309
|
+
table.name,
|
|
310
|
+
]).length;
|
|
311
|
+
const rows = replay
|
|
312
|
+
? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
|
|
313
|
+
: [];
|
|
290
314
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
291
|
-
|
|
315
|
+
const scope = !replay
|
|
316
|
+
? []
|
|
317
|
+
: hasUniqueIndex(table)
|
|
318
|
+
? [JSON.stringify([table.name])]
|
|
319
|
+
: [
|
|
320
|
+
...(after === undefined && options.clearFirst
|
|
321
|
+
? bootstrapScope(db, table, options.effective)
|
|
322
|
+
: []),
|
|
323
|
+
...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
|
|
324
|
+
];
|
|
325
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
292
326
|
if (after === undefined && options.clearFirst)
|
|
293
327
|
deleteScopedRows(db, table, options.effective);
|
|
294
328
|
db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
|
|
295
329
|
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
330
|
+
if (protectedRows)
|
|
331
|
+
for (const row of rows) {
|
|
332
|
+
retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
|
|
333
|
+
column.name,
|
|
334
|
+
rowValueToJson(row[column.name]),
|
|
335
|
+
])), Number(row._syncular_version));
|
|
336
|
+
}
|
|
337
|
+
return scope;
|
|
302
338
|
});
|
|
303
339
|
options.onProgress?.(applied);
|
|
304
340
|
await yieldToHost();
|
|
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
347
383
|
if (!clearThisBlock && rows.length === 0)
|
|
348
384
|
continue;
|
|
349
385
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
350
|
-
const
|
|
386
|
+
const scope = hasUniqueIndex(table)
|
|
387
|
+
? [JSON.stringify([table.name])]
|
|
388
|
+
: [
|
|
389
|
+
...(clearThisBlock
|
|
390
|
+
? bootstrapScope(db, table, options.effective)
|
|
391
|
+
: []),
|
|
392
|
+
...rows.map((row) => JSON.stringify([
|
|
393
|
+
table.name,
|
|
394
|
+
String(row.values[table.primaryKeyIndex]),
|
|
395
|
+
])),
|
|
396
|
+
];
|
|
397
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
351
398
|
if (clearThisBlock) {
|
|
352
399
|
deleteScopedRows(db, table, options.effective);
|
|
353
400
|
}
|
|
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
357
404
|
if (applied % 1024 === 0)
|
|
358
405
|
options.onProgress?.(applied);
|
|
359
406
|
}
|
|
407
|
+
return scope;
|
|
360
408
|
});
|
|
361
409
|
options.onProgress?.(applied);
|
|
362
410
|
await yieldToHost();
|
|
@@ -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/blob.d.ts
CHANGED
package/dist/blob.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
|
|
1
2
|
/**
|
|
2
3
|
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
4
|
*
|
|
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
70
71
|
if (total <= maxBytes)
|
|
71
72
|
return [];
|
|
72
73
|
// Oldest eligible body first, with blob ID as a stable tie.
|
|
73
|
-
const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
74
|
+
const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
|
|
74
75
|
const evicted = [];
|
|
75
76
|
db.transaction(() => {
|
|
76
77
|
for (const row of candidates) {
|
|
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
|
|
|
86
87
|
}
|
|
87
88
|
export function listPendingUploads(db) {
|
|
88
89
|
return db
|
|
89
|
-
.query(
|
|
90
|
+
.query(`SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM ${BLOB_COMMIT_REFS} r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id`)
|
|
90
91
|
.map((row) => {
|
|
91
92
|
if (typeof row.blob_id !== 'string' ||
|
|
92
93
|
(row.media_type !== null && typeof row.media_type !== 'string')) {
|
|
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
|
|
|
138
139
|
: selects.join(' UNION ');
|
|
139
140
|
}
|
|
140
141
|
export function deleteUnreferencedCachedBlobs(db, schema) {
|
|
141
|
-
db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
142
|
+
db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
|
|
142
143
|
}
|
|
143
144
|
export { parseBlobRef, serializeBlobRef };
|
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
|