@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.
@@ -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/blob.ts 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
  *
@@ -187,7 +188,7 @@ export function enforceBlobCacheCap(
187
188
  if (total <= maxBytes) return [];
188
189
  // Oldest eligible body first, with blob ID as a stable tie.
189
190
  const candidates = db.query(
190
- `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 _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`,
191
+ `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`,
191
192
  );
192
193
  const evicted: string[] = [];
193
194
  db.transaction(() => {
@@ -207,7 +208,7 @@ export function listPendingUploads(
207
208
  ): { blobId: string; mediaType?: string }[] {
208
209
  return db
209
210
  .query(
210
- '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 _syncular_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',
211
+ `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`,
211
212
  )
212
213
  .map((row) => {
213
214
  if (
@@ -285,7 +286,7 @@ export function deleteUnreferencedCachedBlobs(
285
286
  schema: CompiledClientSchema,
286
287
  ): void {
287
288
  db.exec(
288
- `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 _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`,
289
+ `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)})`,
289
290
  );
290
291
  }
291
292