@syncular/client 0.22.0 → 0.23.0

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,732 @@
1
+ import { ClientSyncError } from './errors.js';
2
+ import { INVALID_HOST_RESPONSE_CODE } from './local-rebootstrap.js';
3
+ import { fromSqlValue, jsonToRowValue, localColumnType, quoteIdent, rowValueToJson, } from './schema.js';
4
+ import { getMeta, setMeta } from './state.js';
5
+ /** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
6
+ const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
7
+ /**
8
+ * `_syncular_meta` key holding only a capture REFUSAL. The successful record
9
+ * lives inside the container file, so this key is absent whenever a container
10
+ * is present. It stays in the replica because it is small, typed, and useful
11
+ * even when nothing was captured.
12
+ */
13
+ const PREVIOUS_VERSION_CONTEXT_KEY = 'previousVersionContext';
14
+ /** `_syncular_meta` key holding the pre-reset compatibility audit (D6). */
15
+ const PREVIOUS_VERSION_AUDIT_KEY = 'previousVersionAudit';
16
+ /**
17
+ * RFC 0005: the code-derived sibling database filename. Kept out of the replica
18
+ * as data minimisation, not as a security control.
19
+ */
20
+ export const PREVIOUS_VERSION_CONTAINER_NAME = 'prev-context';
21
+ /** The one non-reserved row table INSIDE the container file (D3). */
22
+ const CONTAINER_TABLE = 'syncular_prev_context';
23
+ /** Container-local metadata table holding {@link PreviousVersionRecord}. */
24
+ const CONTAINER_META_TABLE = '_syncular_prev_context_meta';
25
+ const DESCRIPTOR_VERSION = 1;
26
+ const AUDIT_VERSION = 1;
27
+ /** Rows copied per `INSERT` batch (A4 step 5). */
28
+ const COPY_BATCH_ROWS = 500;
29
+ /** Incompatible commits recorded at most (D6); the rest flag `truncated`. */
30
+ const MAX_AUDIT_INCOMPATIBLE = 200;
31
+ /** Read spec `limit` ceiling (D7). */
32
+ export const PREVIOUS_VERSION_MAX_LIMIT = 200;
33
+ function isCount(value) {
34
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
35
+ }
36
+ function invalidHostResponse(method) {
37
+ throw new ClientSyncError(INVALID_HOST_RESPONSE_CODE, `${method} returned an invalid host response`);
38
+ }
39
+ function isSqlValue(value) {
40
+ return (value === null ||
41
+ typeof value === 'string' ||
42
+ typeof value === 'boolean' ||
43
+ typeof value === 'bigint' ||
44
+ (typeof value === 'number' && Number.isFinite(value)) ||
45
+ value instanceof Uint8Array);
46
+ }
47
+ function isSqlRow(value) {
48
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
49
+ return false;
50
+ }
51
+ return Object.values(value).every(isSqlValue);
52
+ }
53
+ const PREVIOUS_VERSION_REASONS = [
54
+ 'not-configured',
55
+ 'no-previous-descriptor',
56
+ 'capture-exceeded-budget',
57
+ 'coverage-complete',
58
+ 'expired',
59
+ 'lease-inactive',
60
+ 'scope-revoked',
61
+ ];
62
+ const SNAPSHOT_REQUIRED_KEYS = [
63
+ 'available',
64
+ 'currentVersion',
65
+ 'rows',
66
+ 'state',
67
+ 'truncated',
68
+ ];
69
+ const SNAPSHOT_OPTIONAL_KEYS = ['previousVersion', 'reason'];
70
+ const DISCARD_KEYS = ['discarded', 'present'];
71
+ const AUDIT_KEYS = [
72
+ 'atMs',
73
+ 'encodable',
74
+ 'fromVersion',
75
+ 'incompatible',
76
+ 'pending',
77
+ 'toVersion',
78
+ 'truncated',
79
+ 'v',
80
+ ];
81
+ const AUDIT_ENTRY_KEYS = ['column', 'commitId', 'reason', 'table'];
82
+ /** Parse a persisted JSON object; anything else is `undefined`, never a guess. */
83
+ function parseJsonObject(raw) {
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ return parsed !== null &&
87
+ typeof parsed === 'object' &&
88
+ !Array.isArray(parsed)
89
+ ? parsed
90
+ : undefined;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ function localCorrupt(what) {
97
+ throw new ClientSyncError('sync.local_corrupt', `persisted ${what} is invalid`);
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Descriptor (D1)
101
+ // ---------------------------------------------------------------------------
102
+ const COLUMN_TYPES = new Set([
103
+ 'string',
104
+ 'integer',
105
+ 'float',
106
+ 'boolean',
107
+ 'bytes',
108
+ 'json',
109
+ 'blob_ref',
110
+ 'crdt',
111
+ ]);
112
+ function decodeColumns(value) {
113
+ if (!Array.isArray(value))
114
+ return localCorrupt('schema descriptor');
115
+ return value.map((entry) => {
116
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
117
+ return localCorrupt('schema descriptor');
118
+ }
119
+ const column = entry;
120
+ const keys = Object.keys(column).sort();
121
+ if (keys.length !== 2 ||
122
+ keys[0] !== 'name' ||
123
+ keys[1] !== 'type' ||
124
+ typeof column.name !== 'string' ||
125
+ typeof column.type !== 'string' ||
126
+ !COLUMN_TYPES.has(column.type)) {
127
+ return localCorrupt('schema descriptor');
128
+ }
129
+ return { name: column.name, type: column.type };
130
+ });
131
+ }
132
+ function decodeTable(value) {
133
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
134
+ return localCorrupt('schema descriptor');
135
+ }
136
+ const table = value;
137
+ const keys = Object.keys(table).sort();
138
+ if (keys.length !== 3 ||
139
+ keys[0] !== 'columns' ||
140
+ keys[1] !== 'name' ||
141
+ keys[2] !== 'primaryKey' ||
142
+ typeof table.name !== 'string' ||
143
+ typeof table.primaryKey !== 'string') {
144
+ return localCorrupt('schema descriptor');
145
+ }
146
+ return {
147
+ name: table.name,
148
+ primaryKey: table.primaryKey,
149
+ columns: decodeColumns(table.columns),
150
+ };
151
+ }
152
+ /**
153
+ * D1: the semantic local types of every table in the running generated schema,
154
+ * built from {@link localColumnType} — never from SQLite affinity.
155
+ */
156
+ function buildLocalSchemaDescriptor(schema) {
157
+ return {
158
+ v: DESCRIPTOR_VERSION,
159
+ version: schema.version,
160
+ tables: [...schema.tables.values()].map((table) => ({
161
+ name: table.name,
162
+ primaryKey: table.primaryKey,
163
+ columns: table.columns.map((column) => ({
164
+ name: column.name,
165
+ type: localColumnType(column),
166
+ })),
167
+ })),
168
+ };
169
+ }
170
+ /** Strict decode: an unknown shape is corruption, never a best guess. */
171
+ function decodeLocalSchemaDescriptor(value) {
172
+ const record = parseJsonObject(value);
173
+ if (record === undefined)
174
+ return localCorrupt('schema descriptor');
175
+ const keys = Object.keys(record).sort();
176
+ if (keys.length !== 3 ||
177
+ keys[0] !== 'tables' ||
178
+ keys[1] !== 'v' ||
179
+ keys[2] !== 'version' ||
180
+ record.v !== DESCRIPTOR_VERSION ||
181
+ !isCount(record.version) ||
182
+ !Array.isArray(record.tables)) {
183
+ return localCorrupt('schema descriptor');
184
+ }
185
+ return {
186
+ v: 1,
187
+ version: record.version,
188
+ tables: record.tables.map(decodeTable),
189
+ };
190
+ }
191
+ /**
192
+ * D1: persist the descriptor. Callers MUST write this in the same transaction
193
+ * as {@link LOCAL_SCHEMA_VERSION_KEY}; a descriptor without a matching marker
194
+ * is treated as absent at reset time.
195
+ */
196
+ export function setLocalSchemaDescriptor(db, schema) {
197
+ setMeta(db, LOCAL_SCHEMA_DESCRIPTOR_KEY, JSON.stringify(buildLocalSchemaDescriptor(schema)));
198
+ }
199
+ /** Load and strictly decode the stored descriptor; `undefined` when absent or corrupt. */
200
+ export function loadLocalSchemaDescriptor(db) {
201
+ const raw = getMeta(db, LOCAL_SCHEMA_DESCRIPTOR_KEY);
202
+ if (raw === undefined)
203
+ return undefined;
204
+ try {
205
+ return decodeLocalSchemaDescriptor(raw);
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Container file (D3, moved off the replica connection)
213
+ // ---------------------------------------------------------------------------
214
+ /** Drop both container tables. Also the unconditional orphan sweep (D5). */
215
+ export function dropPreviousVersionContainer(db) {
216
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdent(CONTAINER_TABLE)}`);
217
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdent(CONTAINER_META_TABLE)}`);
218
+ }
219
+ /**
220
+ * Run `fn` against the container file for exactly one open window, closing the
221
+ * handle afterwards. The container is only opened where `siblingExists` already
222
+ * reports it — probing never creates the file. `removeFile` deletes the file
223
+ * once the handle is closed (a sweep or a discard must leave none). Returns
224
+ * `undefined` when the file or the host's sibling capability is absent.
225
+ */
226
+ export function withPreviousVersionContainer(db, fn, removeFile = false) {
227
+ if (db.siblingExists?.(PREVIOUS_VERSION_CONTAINER_NAME) !== true) {
228
+ return undefined;
229
+ }
230
+ const handle = db.openSibling?.(PREVIOUS_VERSION_CONTAINER_NAME);
231
+ if (handle === undefined)
232
+ return undefined;
233
+ try {
234
+ return fn(handle.database);
235
+ }
236
+ finally {
237
+ handle.close();
238
+ if (removeFile)
239
+ handle.removeFile();
240
+ }
241
+ }
242
+ /** Read the container's own metadata record; `undefined` when absent/corrupt. */
243
+ export function readPreviousVersionContainer(db) {
244
+ const meta = db.query("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?", [CONTAINER_META_TABLE]);
245
+ if (meta.length === 0)
246
+ return undefined;
247
+ const raw = db.query(`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id = 1`)[0]?.record;
248
+ if (raw === undefined)
249
+ return undefined;
250
+ try {
251
+ return decodeRecord(String(raw));
252
+ }
253
+ catch {
254
+ return undefined;
255
+ }
256
+ }
257
+ function decodeRecord(value) {
258
+ const record = parseJsonObject(value);
259
+ if (record === undefined)
260
+ return localCorrupt('previous-version context');
261
+ const keys = Object.keys(record).sort();
262
+ if (keys.length !== 7 ||
263
+ keys[0] !== 'bytes' ||
264
+ keys[1] !== 'createdAtMs' ||
265
+ keys[2] !== 'currentVersion' ||
266
+ keys[3] !== 'previousVersion' ||
267
+ keys[4] !== 'rows' ||
268
+ keys[5] !== 'tables' ||
269
+ keys[6] !== 'v' ||
270
+ record.v !== 1 ||
271
+ !isCount(record.bytes) ||
272
+ !isCount(record.createdAtMs) ||
273
+ !isCount(record.currentVersion) ||
274
+ !isCount(record.previousVersion) ||
275
+ !isCount(record.rows) ||
276
+ !Array.isArray(record.tables)) {
277
+ return localCorrupt('previous-version context');
278
+ }
279
+ return {
280
+ v: 1,
281
+ previousVersion: record.previousVersion,
282
+ currentVersion: record.currentVersion,
283
+ tables: record.tables.map(decodeTable),
284
+ rows: record.rows,
285
+ bytes: record.bytes,
286
+ createdAtMs: record.createdAtMs,
287
+ };
288
+ }
289
+ function writeRecord(db, record) {
290
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
291
+ id INTEGER PRIMARY KEY CHECK (id = 1),
292
+ record TEXT NOT NULL)`);
293
+ db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
294
+ db.exec(`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id, record) VALUES (1, ?)`, [JSON.stringify(record)]);
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // Bounded capture (D2 / A4)
298
+ // ---------------------------------------------------------------------------
299
+ /**
300
+ * D2/A4: measure the capture BEFORE materializing anything. Ordered probes,
301
+ * aborting on the first violation:
302
+ *
303
+ * 1. table count from `sqlite_master`;
304
+ * 2. per-table row count with a `LIMIT maxRows + 1` early-abort probe;
305
+ * 3. a single-row probe for rows larger than `maxRowBytes`;
306
+ * 4. a bounded `SUM` over a `LIMIT`ed subquery;
307
+ * 5. the copy itself into the CONTAINER file, in {@link COPY_BATCH_ROWS}
308
+ * batches and one container transaction.
309
+ */
310
+ export function capturePreviousVersion(replica, container, oldDescriptor, newSchema, config, nowMs) {
311
+ const discovered = new Set(replica
312
+ .query(`SELECT name FROM sqlite_master WHERE type = 'table'
313
+ AND name NOT LIKE '_syncular_%'
314
+ AND name NOT LIKE 'sqlite_%'`)
315
+ .map((row) => String(row.name)));
316
+ // Only descriptor-known tables carry semantic types; those are the tables the
317
+ // copy would hold, and the count the table budget bounds.
318
+ const tables = oldDescriptor.tables.filter((table) => discovered.has(table.name));
319
+ const over = (rows, bytes) => ({
320
+ ok: false,
321
+ reason: 'capture-exceeded-budget',
322
+ measurement: { tables: tables.length, rows, bytes },
323
+ });
324
+ if (tables.length > config.maxTables)
325
+ return over(0, 0);
326
+ let measuredRows = 0;
327
+ const perTableRows = [];
328
+ for (const table of tables) {
329
+ const probeRows = Number(replica.query(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`, [config.maxRows + 1])[0]?.count ?? 0);
330
+ measuredRows += probeRows;
331
+ perTableRows.push(probeRows);
332
+ if (measuredRows > config.maxRows)
333
+ return over(measuredRows, 0);
334
+ }
335
+ let measuredBytes = 0;
336
+ for (let index = 0; index < tables.length; index++) {
337
+ const table = tables[index];
338
+ const byteSum = table.columns
339
+ .map((c) => `COALESCE(LENGTH(CAST(${quoteIdent(c.name)} AS BLOB)), 0)`)
340
+ .join(' + ');
341
+ if (table.columns.length === 0)
342
+ continue;
343
+ const oversized = replica.query(`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
344
+ WHERE (${byteSum}) > ? LIMIT 1`, [config.maxRowBytes]);
345
+ if (oversized.length > 0)
346
+ return over(measuredRows, measuredBytes);
347
+ const total = replica.query(`SELECT COALESCE(SUM(bytes), 0) AS total FROM (
348
+ SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
349
+ )`, [perTableRows[index] ?? 0])[0]?.total;
350
+ measuredBytes += Number(total ?? 0);
351
+ if (measuredBytes > config.maxBytes)
352
+ return over(measuredRows, measuredBytes);
353
+ }
354
+ let copiedRows = 0;
355
+ container.transaction(() => {
356
+ dropPreviousVersionContainer(container);
357
+ container.exec(`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
358
+ tbl TEXT NOT NULL,
359
+ row_id TEXT NOT NULL,
360
+ payload TEXT NOT NULL,
361
+ PRIMARY KEY (tbl, row_id))`);
362
+ for (const table of tables) {
363
+ const selectColumns = table.columns
364
+ .map((column) => quoteIdent(column.name))
365
+ .join(', ');
366
+ let lastRowId = -1;
367
+ for (;;) {
368
+ const rows = replica.query(`SELECT rowid AS _rid, ${selectColumns} FROM ${quoteIdent(table.name)}
369
+ WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`, [lastRowId, COPY_BATCH_ROWS]);
370
+ if (rows.length === 0)
371
+ break;
372
+ for (const row of rows) {
373
+ const payload = {};
374
+ for (const column of table.columns) {
375
+ const value = fromSqlValue({ name: column.name, type: column.type, nullable: true }, (row[column.name] ?? null));
376
+ payload[column.name] = rowValueToJson(value);
377
+ }
378
+ container.exec(`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl, row_id, payload)
379
+ VALUES (?, ?, ?)`, [table.name, rowIdText(table, row), JSON.stringify(payload)]);
380
+ }
381
+ lastRowId = Number(rows[rows.length - 1]?._rid ?? lastRowId);
382
+ copiedRows += rows.length;
383
+ }
384
+ }
385
+ writeRecord(container, {
386
+ v: 1,
387
+ previousVersion: oldDescriptor.version,
388
+ currentVersion: newSchema.version,
389
+ tables: tables.map((table) => ({
390
+ name: table.name,
391
+ primaryKey: table.primaryKey,
392
+ columns: table.columns,
393
+ })),
394
+ rows: copiedRows,
395
+ bytes: measuredBytes,
396
+ createdAtMs: nowMs,
397
+ });
398
+ });
399
+ return {
400
+ ok: true,
401
+ measurement: {
402
+ tables: tables.length,
403
+ rows: copiedRows,
404
+ bytes: measuredBytes,
405
+ },
406
+ };
407
+ }
408
+ function rowIdText(table, row) {
409
+ const value = row[table.primaryKey] ?? null;
410
+ if (value === null)
411
+ return '';
412
+ if (value instanceof Uint8Array) {
413
+ return [...value]
414
+ .map((byte) => byte.toString(16).padStart(2, '0'))
415
+ .join('');
416
+ }
417
+ return String(value);
418
+ }
419
+ function decodePreviousVersionPayload(table, payload) {
420
+ const record = parseJsonObject(payload);
421
+ if (record === undefined)
422
+ return localCorrupt('previous-version payload');
423
+ const row = {};
424
+ for (const column of table.columns) {
425
+ row[column.name] = jsonToRowValue((record[column.name] ?? null));
426
+ }
427
+ return row;
428
+ }
429
+ /** D7: read one previous table from the container, `limit + 1` rows for truncation. */
430
+ export function readPreviousVersionRows(db, table, rowIds, limit) {
431
+ const where = rowIds.length === 0
432
+ ? ''
433
+ : ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
434
+ const rows = db.query(`SELECT row_id, payload FROM ${quoteIdent(CONTAINER_TABLE)}
435
+ WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`, [table.name, ...rowIds, limit + 1]);
436
+ const truncated = rows.length > limit;
437
+ const visible = truncated ? rows.slice(0, limit) : rows;
438
+ return {
439
+ rows: visible.map((row) => decodePreviousVersionPayload(table, String(row.payload))),
440
+ truncated,
441
+ };
442
+ }
443
+ // ---------------------------------------------------------------------------
444
+ // Durable refusal (read surface for a capture that stored nothing)
445
+ // ---------------------------------------------------------------------------
446
+ export function writePreviousVersionRefusal(db, reason, measurement) {
447
+ const refusal = {
448
+ v: 1,
449
+ reason,
450
+ tables: measurement.tables,
451
+ rows: measurement.rows,
452
+ bytes: measurement.bytes,
453
+ };
454
+ setMeta(db, PREVIOUS_VERSION_CONTEXT_KEY, JSON.stringify(refusal));
455
+ }
456
+ /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
457
+ export function clearPreviousVersionRefusal(db) {
458
+ db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
459
+ PREVIOUS_VERSION_CONTEXT_KEY,
460
+ ]);
461
+ }
462
+ /** Decode the durable refusal; `undefined` when absent or corrupt. */
463
+ export function storedPreviousVersionRefusal(db) {
464
+ const raw = getMeta(db, PREVIOUS_VERSION_CONTEXT_KEY);
465
+ if (raw === undefined)
466
+ return undefined;
467
+ const record = parseJsonObject(raw);
468
+ if (record === undefined)
469
+ return undefined;
470
+ if (record.v !== 1 ||
471
+ (record.reason !== 'no-previous-descriptor' &&
472
+ record.reason !== 'capture-exceeded-budget')) {
473
+ return undefined;
474
+ }
475
+ return {
476
+ v: 1,
477
+ reason: record.reason,
478
+ ...(isCount(record.tables) ? { tables: record.tables } : {}),
479
+ ...(isCount(record.rows) ? { rows: record.rows } : {}),
480
+ ...(isCount(record.bytes) ? { bytes: record.bytes } : {}),
481
+ };
482
+ }
483
+ /**
484
+ * D6: classify pending outbox commits against the NEW compiled schema, before
485
+ * the wipe. It records the commit id, table, typed reason and offending column
486
+ * ONLY — never an operation, row value or envelope field. The envelope stays in
487
+ * the outbox (and, on a terminal drop, in the §7.2.1 journal).
488
+ */
489
+ export function buildPreviousVersionAudit(schema, pending, fromVersion, toVersion, atMs) {
490
+ const incompatible = [];
491
+ let incompatibleTotal = 0;
492
+ let encodable = 0;
493
+ for (const commit of pending) {
494
+ const entry = firstIncompatibility(schema, commit);
495
+ if (entry === undefined) {
496
+ encodable += 1;
497
+ continue;
498
+ }
499
+ incompatibleTotal += 1;
500
+ if (incompatible.length < MAX_AUDIT_INCOMPATIBLE)
501
+ incompatible.push(entry);
502
+ }
503
+ return {
504
+ v: AUDIT_VERSION,
505
+ atMs,
506
+ fromVersion,
507
+ toVersion,
508
+ pending: pending.length,
509
+ encodable,
510
+ truncated: incompatibleTotal > MAX_AUDIT_INCOMPATIBLE,
511
+ incompatible,
512
+ };
513
+ }
514
+ function firstIncompatibility(schema, commit) {
515
+ for (const operation of commit.operations) {
516
+ const table = schema.tables.get(operation.table);
517
+ if (table === undefined) {
518
+ return {
519
+ commitId: commit.clientCommitId,
520
+ table: operation.table,
521
+ reason: 'unknown-table',
522
+ };
523
+ }
524
+ if (operation.values === undefined)
525
+ continue;
526
+ for (const key of Object.keys(operation.values)) {
527
+ if (!table.columnIndex.has(key)) {
528
+ return {
529
+ commitId: commit.clientCommitId,
530
+ table: operation.table,
531
+ reason: 'unknown-column',
532
+ column: key,
533
+ };
534
+ }
535
+ }
536
+ }
537
+ return undefined;
538
+ }
539
+ /** Strict decode; `undefined` when absent or malformed. */
540
+ export function storedPreviousVersionAudit(db) {
541
+ const raw = getMeta(db, PREVIOUS_VERSION_AUDIT_KEY);
542
+ if (raw === undefined)
543
+ return undefined;
544
+ const record = parseJsonObject(raw);
545
+ if (record === undefined)
546
+ return undefined;
547
+ if (record.v !== AUDIT_VERSION ||
548
+ !isCount(record.atMs) ||
549
+ !isCount(record.fromVersion) ||
550
+ !isCount(record.toVersion) ||
551
+ !isCount(record.pending) ||
552
+ !isCount(record.encodable) ||
553
+ typeof record.truncated !== 'boolean' ||
554
+ !Array.isArray(record.incompatible)) {
555
+ return undefined;
556
+ }
557
+ const incompatible = [];
558
+ for (const entry of record.incompatible) {
559
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
560
+ return undefined;
561
+ }
562
+ const item = entry;
563
+ if (typeof item.commitId !== 'string' ||
564
+ typeof item.table !== 'string' ||
565
+ (item.reason !== 'unknown-table' && item.reason !== 'unknown-column') ||
566
+ (item.column !== undefined && typeof item.column !== 'string')) {
567
+ return undefined;
568
+ }
569
+ incompatible.push({
570
+ commitId: item.commitId,
571
+ table: item.table,
572
+ reason: item.reason,
573
+ ...(typeof item.column === 'string' ? { column: item.column } : {}),
574
+ });
575
+ }
576
+ return {
577
+ v: 1,
578
+ atMs: record.atMs,
579
+ fromVersion: record.fromVersion,
580
+ toVersion: record.toVersion,
581
+ pending: record.pending,
582
+ encodable: record.encodable,
583
+ truncated: record.truncated,
584
+ incompatible,
585
+ };
586
+ }
587
+ export function writePreviousVersionAudit(db, audit) {
588
+ setMeta(db, PREVIOUS_VERSION_AUDIT_KEY, JSON.stringify(audit));
589
+ }
590
+ /** Delete the advisory audit. Part of a discard, never of a normal bump. */
591
+ export function clearPreviousVersionAudit(db) {
592
+ db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
593
+ PREVIOUS_VERSION_AUDIT_KEY,
594
+ ]);
595
+ }
596
+ // ---------------------------------------------------------------------------
597
+ // Strict bridge decoders (RFC 0005 D10)
598
+ //
599
+ // Compile-time host types are not runtime proof. A version-drifted Worker or
600
+ // native bridge could answer a read command with a forged `available: true`
601
+ // and a peer's protected rows, so every reply is validated against the closed
602
+ // public shape before an application can observe it.
603
+ // ---------------------------------------------------------------------------
604
+ /** Strictly decode a Worker/native `previousVersionSnapshot` reply. */
605
+ export function decodePreviousVersionSnapshot(value) {
606
+ const method = 'previousVersionSnapshot';
607
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
608
+ return invalidHostResponse(method);
609
+ }
610
+ const source = value;
611
+ const allowed = new Set([
612
+ ...SNAPSHOT_REQUIRED_KEYS,
613
+ ...SNAPSHOT_OPTIONAL_KEYS,
614
+ ]);
615
+ const keys = Object.keys(source);
616
+ if (!keys.every((key) => allowed.has(key)) ||
617
+ SNAPSHOT_REQUIRED_KEYS.some((key) => !(key in source)) ||
618
+ source.state !== 'previousVersion' ||
619
+ typeof source.available !== 'boolean' ||
620
+ !isCount(source.currentVersion) ||
621
+ typeof source.truncated !== 'boolean' ||
622
+ !Array.isArray(source.rows) ||
623
+ !source.rows.every(isSqlRow)) {
624
+ return invalidHostResponse(method);
625
+ }
626
+ const reason = source.reason;
627
+ const previousVersion = source.previousVersion;
628
+ const rows = source.rows;
629
+ if (source.available) {
630
+ // An available read names the previous version and never a refusal.
631
+ if (!isCount(previousVersion) || reason !== undefined) {
632
+ return invalidHostResponse(method);
633
+ }
634
+ return {
635
+ state: 'previousVersion',
636
+ available: true,
637
+ previousVersion,
638
+ currentVersion: source.currentVersion,
639
+ rows,
640
+ truncated: source.truncated,
641
+ };
642
+ }
643
+ if (previousVersion !== undefined ||
644
+ reason === undefined ||
645
+ !PREVIOUS_VERSION_REASONS.includes(reason)) {
646
+ return invalidHostResponse(method);
647
+ }
648
+ return {
649
+ state: 'previousVersion',
650
+ available: false,
651
+ currentVersion: source.currentVersion,
652
+ reason: reason,
653
+ rows,
654
+ truncated: source.truncated,
655
+ };
656
+ }
657
+ /**
658
+ * Strictly decode a Worker/native `previousVersionAudit` reply. `undefined` is
659
+ * the legitimate "no audit recorded" answer; anything else malformed throws.
660
+ */
661
+ export function decodePreviousVersionAuditResult(value) {
662
+ const method = 'previousVersionAudit';
663
+ if (value === undefined)
664
+ return undefined;
665
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
666
+ return invalidHostResponse(method);
667
+ }
668
+ const source = value;
669
+ const keys = Object.keys(source).sort();
670
+ if (keys.length !== AUDIT_KEYS.length)
671
+ return invalidHostResponse(method);
672
+ if (keys.some((key, index) => key !== AUDIT_KEYS[index])) {
673
+ return invalidHostResponse(method);
674
+ }
675
+ if (source.v !== AUDIT_VERSION ||
676
+ !isCount(source.atMs) ||
677
+ !isCount(source.fromVersion) ||
678
+ !isCount(source.toVersion) ||
679
+ !isCount(source.pending) ||
680
+ !isCount(source.encodable) ||
681
+ typeof source.truncated !== 'boolean' ||
682
+ !Array.isArray(source.incompatible) ||
683
+ source.incompatible.length > MAX_AUDIT_INCOMPATIBLE) {
684
+ return invalidHostResponse(method);
685
+ }
686
+ const incompatible = [];
687
+ for (const entry of source.incompatible) {
688
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
689
+ return invalidHostResponse(method);
690
+ }
691
+ const item = entry;
692
+ if (!Object.keys(item).every((key) => AUDIT_ENTRY_KEYS.includes(key)) ||
693
+ typeof item.commitId !== 'string' ||
694
+ typeof item.table !== 'string' ||
695
+ (item.reason !== 'unknown-table' && item.reason !== 'unknown-column') ||
696
+ (item.column !== undefined && typeof item.column !== 'string')) {
697
+ return invalidHostResponse(method);
698
+ }
699
+ incompatible.push({
700
+ commitId: item.commitId,
701
+ table: item.table,
702
+ reason: item.reason,
703
+ ...(typeof item.column === 'string' ? { column: item.column } : {}),
704
+ });
705
+ }
706
+ return {
707
+ v: 1,
708
+ atMs: source.atMs,
709
+ fromVersion: source.fromVersion,
710
+ toVersion: source.toVersion,
711
+ pending: source.pending,
712
+ encodable: source.encodable,
713
+ truncated: source.truncated,
714
+ incompatible,
715
+ };
716
+ }
717
+ /** Strictly decode a Worker/native `previousVersionDiscard` reply. */
718
+ export function decodePreviousVersionDiscardResult(value) {
719
+ const method = 'previousVersionDiscard';
720
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
721
+ return invalidHostResponse(method);
722
+ }
723
+ const source = value;
724
+ const keys = Object.keys(source).sort();
725
+ if (keys.length !== DISCARD_KEYS.length ||
726
+ keys.some((key, index) => key !== DISCARD_KEYS[index]) ||
727
+ typeof source.present !== 'boolean' ||
728
+ typeof source.discarded !== 'boolean') {
729
+ return invalidHostResponse(method);
730
+ }
731
+ return { present: source.present, discarded: source.discarded };
732
+ }