@syncular/client 0.22.0 → 0.24.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.
- package/dist/bun-database.d.ts +4 -1
- package/dist/bun-database.js +24 -1
- package/dist/client.d.ts +47 -0
- package/dist/client.js +326 -4
- package/dist/database.d.ts +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/invalidation.d.ts +9 -0
- package/dist/node-database.d.ts +4 -1
- package/dist/node-database.js +24 -1
- package/dist/previous-version.d.ts +207 -0
- package/dist/previous-version.js +732 -0
- package/dist/query-guard.d.ts +5 -0
- package/dist/query-guard.js +5 -0
- package/dist/schema.d.ts +4 -2
- package/dist/schema.js +4 -2
- package/dist/wasm-database.js +87 -35
- package/dist/worker-entry.js +15 -4
- package/dist/worker-host.d.ts +13 -1
- package/dist/worker-host.js +13 -0
- package/dist/worker-protocol.d.ts +16 -1
- package/package.json +3 -3
- package/src/bun-database.ts +26 -1
- package/src/client.ts +481 -4
- package/src/database.ts +28 -0
- package/src/index.ts +13 -0
- package/src/invalidation.ts +9 -0
- package/src/node-database.ts +26 -1
- package/src/previous-version.ts +1070 -0
- package/src/query-guard.ts +5 -0
- package/src/schema.ts +4 -2
- package/src/wasm-database.ts +114 -47
- package/src/worker-entry.ts +16 -4
- package/src/worker-host.ts +40 -0
- package/src/worker-protocol.ts +19 -0
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0005 previous-version context.
|
|
3
|
+
*
|
|
4
|
+
* A schema bump wipes the local replica (§7.4.3), so the rows the app could
|
|
5
|
+
* see a moment ago are gone before the server can re-bootstrap them. This
|
|
6
|
+
* module captures a bounded, typed, read-only copy of the pre-reset rows so
|
|
7
|
+
* the app can answer "what did this look like before the upgrade" while the
|
|
8
|
+
* replacement bootstrap is in flight.
|
|
9
|
+
*
|
|
10
|
+
* Storage (controller storage move, 2026-09-19): the captured rows live in a
|
|
11
|
+
* SECOND database file in the replica's own pool directory, opened through
|
|
12
|
+
* `ClientDatabase.openSibling`, never in the replica database. The guarantee
|
|
13
|
+
* this buys is stated narrowly: an older client's ordinary query connection
|
|
14
|
+
* does not attach this file, so no SQL it runs can reach the container. It is
|
|
15
|
+
* NOT protection against arbitrary same-origin access and NOT protection
|
|
16
|
+
* against native filesystem access. The filename is a code constant derived at
|
|
17
|
+
* runtime and is not persisted in the replica database. That is data
|
|
18
|
+
* minimisation hygiene, NOT a control: same-origin storage access and native
|
|
19
|
+
* filesystem access can enumerate names regardless, so nothing is claimed on
|
|
20
|
+
* its strength.
|
|
21
|
+
*
|
|
22
|
+
* Two further rules shape the implementation:
|
|
23
|
+
*
|
|
24
|
+
* - The capture is typed by the OLD schema's {@link LocalSchemaDescriptor},
|
|
25
|
+
* persisted in `_syncular_meta` beside the schema-version marker. SQLite
|
|
26
|
+
* column affinity cannot recover a semantic type (`boolean` and `integer`
|
|
27
|
+
* are both `INTEGER`; `string`, `json` and `blob_ref` are all `TEXT`), so a
|
|
28
|
+
* descriptor-free capture would be undecodable. It is NEVER inferred.
|
|
29
|
+
* - Every budget is measured before any row is materialized. An over-budget
|
|
30
|
+
* capture stores nothing.
|
|
31
|
+
*/
|
|
32
|
+
import type { RowColumn, RowValue } from '@syncular/core';
|
|
33
|
+
import type { ClientDatabase, SqlRow, SqlValue } from './database';
|
|
34
|
+
import { ClientSyncError } from './errors';
|
|
35
|
+
import { INVALID_HOST_RESPONSE_CODE } from './local-rebootstrap';
|
|
36
|
+
import {
|
|
37
|
+
type CompiledClientSchema,
|
|
38
|
+
fromSqlValue,
|
|
39
|
+
type JsonRowValue,
|
|
40
|
+
jsonToRowValue,
|
|
41
|
+
localColumnType,
|
|
42
|
+
quoteIdent,
|
|
43
|
+
rowValueToJson,
|
|
44
|
+
} from './schema';
|
|
45
|
+
import { getMeta, setMeta } from './state';
|
|
46
|
+
|
|
47
|
+
/** `_syncular_meta` key holding the persisted {@link LocalSchemaDescriptor}. */
|
|
48
|
+
const LOCAL_SCHEMA_DESCRIPTOR_KEY = 'localSchemaDescriptor';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `_syncular_meta` key holding only a capture REFUSAL. The successful record
|
|
52
|
+
* lives inside the container file, so this key is absent whenever a container
|
|
53
|
+
* is present. It stays in the replica because it is small, typed, and useful
|
|
54
|
+
* even when nothing was captured.
|
|
55
|
+
*/
|
|
56
|
+
const PREVIOUS_VERSION_CONTEXT_KEY = 'previousVersionContext';
|
|
57
|
+
|
|
58
|
+
/** `_syncular_meta` key holding the pre-reset compatibility audit (D6). */
|
|
59
|
+
const PREVIOUS_VERSION_AUDIT_KEY = 'previousVersionAudit';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* RFC 0005: the code-derived sibling database filename. Kept out of the replica
|
|
63
|
+
* as data minimisation, not as a security control.
|
|
64
|
+
*/
|
|
65
|
+
export const PREVIOUS_VERSION_CONTAINER_NAME = 'prev-context';
|
|
66
|
+
|
|
67
|
+
/** The one non-reserved row table INSIDE the container file (D3). */
|
|
68
|
+
const CONTAINER_TABLE = 'syncular_prev_context';
|
|
69
|
+
|
|
70
|
+
/** Container-local metadata table holding {@link PreviousVersionRecord}. */
|
|
71
|
+
const CONTAINER_META_TABLE = '_syncular_prev_context_meta';
|
|
72
|
+
|
|
73
|
+
const DESCRIPTOR_VERSION = 1;
|
|
74
|
+
const AUDIT_VERSION = 1;
|
|
75
|
+
|
|
76
|
+
/** Rows copied per `INSERT` batch (A4 step 5). */
|
|
77
|
+
const COPY_BATCH_ROWS = 500;
|
|
78
|
+
|
|
79
|
+
/** Incompatible commits recorded at most (D6); the rest flag `truncated`. */
|
|
80
|
+
const MAX_AUDIT_INCOMPATIBLE = 200;
|
|
81
|
+
|
|
82
|
+
/** Read spec `limit` ceiling (D7). */
|
|
83
|
+
export const PREVIOUS_VERSION_MAX_LIMIT = 200;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every reason the read surface can name. `security-inactive` is absent because
|
|
87
|
+
* `#requireActive()` throws before a read; `purged` and post-TTL are absent
|
|
88
|
+
* because a discard removes the records, so no durable state can name them;
|
|
89
|
+
* `namespace-collision` is absent because the container is a separate file and
|
|
90
|
+
* cannot collide with a replica table.
|
|
91
|
+
*/
|
|
92
|
+
export type PreviousVersionReason =
|
|
93
|
+
| 'not-configured'
|
|
94
|
+
| 'no-previous-descriptor'
|
|
95
|
+
| 'capture-exceeded-budget'
|
|
96
|
+
| 'coverage-complete'
|
|
97
|
+
| 'expired'
|
|
98
|
+
| 'lease-inactive'
|
|
99
|
+
| 'scope-revoked';
|
|
100
|
+
|
|
101
|
+
/** Refusals that ARE durable, because no container is written for them. */
|
|
102
|
+
type PreviousVersionRefusalReason =
|
|
103
|
+
| 'no-previous-descriptor'
|
|
104
|
+
| 'capture-exceeded-budget';
|
|
105
|
+
|
|
106
|
+
interface LocalSchemaDescriptorColumn {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
readonly type: RowColumn['type'];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface LocalSchemaDescriptorTable {
|
|
112
|
+
readonly name: string;
|
|
113
|
+
readonly primaryKey: string;
|
|
114
|
+
readonly columns: readonly LocalSchemaDescriptorColumn[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface LocalSchemaDescriptor {
|
|
118
|
+
readonly v: 1;
|
|
119
|
+
readonly version: number;
|
|
120
|
+
readonly tables: readonly LocalSchemaDescriptorTable[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Successful capture record, stored INSIDE the container file. */
|
|
124
|
+
export interface PreviousVersionRecord {
|
|
125
|
+
readonly v: 1;
|
|
126
|
+
readonly previousVersion: number;
|
|
127
|
+
readonly currentVersion: number;
|
|
128
|
+
readonly tables: readonly LocalSchemaDescriptorTable[];
|
|
129
|
+
readonly rows: number;
|
|
130
|
+
readonly bytes: number;
|
|
131
|
+
readonly createdAtMs: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Durable refusal stored in `_syncular_meta` when no container is written. */
|
|
135
|
+
interface PreviousVersionRefusal {
|
|
136
|
+
readonly v: 1;
|
|
137
|
+
readonly reason: PreviousVersionRefusalReason;
|
|
138
|
+
readonly tables?: number;
|
|
139
|
+
readonly rows?: number;
|
|
140
|
+
readonly bytes?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface PreviousVersionCaptureConfig {
|
|
144
|
+
readonly maxBytes: number;
|
|
145
|
+
readonly maxRows: number;
|
|
146
|
+
readonly maxTables: number;
|
|
147
|
+
readonly maxRowBytes: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface PreviousVersionCaptureMeasurement {
|
|
151
|
+
readonly tables: number;
|
|
152
|
+
readonly rows: number;
|
|
153
|
+
readonly bytes: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type PreviousVersionCaptureOutcome =
|
|
157
|
+
| {
|
|
158
|
+
readonly ok: true;
|
|
159
|
+
readonly measurement: PreviousVersionCaptureMeasurement;
|
|
160
|
+
}
|
|
161
|
+
| {
|
|
162
|
+
readonly ok: false;
|
|
163
|
+
readonly reason: 'capture-exceeded-budget';
|
|
164
|
+
readonly measurement: PreviousVersionCaptureMeasurement;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
interface PreviousVersionAuditEntry {
|
|
168
|
+
readonly commitId: string;
|
|
169
|
+
readonly table: string;
|
|
170
|
+
readonly reason: 'unknown-table' | 'unknown-column';
|
|
171
|
+
/** The offending column; present for every reason except `unknown-table`. */
|
|
172
|
+
readonly column?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface PreviousVersionAudit {
|
|
176
|
+
readonly v: 1;
|
|
177
|
+
readonly atMs: number;
|
|
178
|
+
readonly fromVersion: number;
|
|
179
|
+
readonly toVersion: number;
|
|
180
|
+
readonly pending: number;
|
|
181
|
+
readonly encodable: number;
|
|
182
|
+
readonly truncated: boolean;
|
|
183
|
+
readonly incompatible: readonly PreviousVersionAuditEntry[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PreviousVersionReadSpec {
|
|
187
|
+
readonly table: string;
|
|
188
|
+
readonly rowIds?: readonly string[];
|
|
189
|
+
readonly limit?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface PreviousVersionSnapshot {
|
|
193
|
+
readonly state: 'previousVersion';
|
|
194
|
+
readonly available: boolean;
|
|
195
|
+
readonly previousVersion?: number;
|
|
196
|
+
readonly currentVersion: number;
|
|
197
|
+
readonly reason?: PreviousVersionReason;
|
|
198
|
+
readonly rows: readonly SqlRow[];
|
|
199
|
+
readonly truncated: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isCount(value: unknown): value is number {
|
|
203
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function invalidHostResponse(method: string): never {
|
|
207
|
+
throw new ClientSyncError(
|
|
208
|
+
INVALID_HOST_RESPONSE_CODE,
|
|
209
|
+
`${method} returned an invalid host response`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isSqlValue(value: unknown): value is SqlValue {
|
|
214
|
+
return (
|
|
215
|
+
value === null ||
|
|
216
|
+
typeof value === 'string' ||
|
|
217
|
+
typeof value === 'boolean' ||
|
|
218
|
+
typeof value === 'bigint' ||
|
|
219
|
+
(typeof value === 'number' && Number.isFinite(value)) ||
|
|
220
|
+
value instanceof Uint8Array
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSqlRow(value: unknown): value is SqlRow {
|
|
225
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return Object.values(value as Record<string, unknown>).every(isSqlValue);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const PREVIOUS_VERSION_REASONS: readonly PreviousVersionReason[] = [
|
|
232
|
+
'not-configured',
|
|
233
|
+
'no-previous-descriptor',
|
|
234
|
+
'capture-exceeded-budget',
|
|
235
|
+
'coverage-complete',
|
|
236
|
+
'expired',
|
|
237
|
+
'lease-inactive',
|
|
238
|
+
'scope-revoked',
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const SNAPSHOT_REQUIRED_KEYS = [
|
|
242
|
+
'available',
|
|
243
|
+
'currentVersion',
|
|
244
|
+
'rows',
|
|
245
|
+
'state',
|
|
246
|
+
'truncated',
|
|
247
|
+
] as const;
|
|
248
|
+
const SNAPSHOT_OPTIONAL_KEYS = ['previousVersion', 'reason'] as const;
|
|
249
|
+
const DISCARD_KEYS = ['discarded', 'present'] as const;
|
|
250
|
+
const AUDIT_KEYS = [
|
|
251
|
+
'atMs',
|
|
252
|
+
'encodable',
|
|
253
|
+
'fromVersion',
|
|
254
|
+
'incompatible',
|
|
255
|
+
'pending',
|
|
256
|
+
'toVersion',
|
|
257
|
+
'truncated',
|
|
258
|
+
'v',
|
|
259
|
+
] as const;
|
|
260
|
+
const AUDIT_ENTRY_KEYS = ['column', 'commitId', 'reason', 'table'] as const;
|
|
261
|
+
|
|
262
|
+
/** Parse a persisted JSON object; anything else is `undefined`, never a guess. */
|
|
263
|
+
function parseJsonObject(raw: string): Record<string, unknown> | undefined {
|
|
264
|
+
try {
|
|
265
|
+
const parsed: unknown = JSON.parse(raw);
|
|
266
|
+
return parsed !== null &&
|
|
267
|
+
typeof parsed === 'object' &&
|
|
268
|
+
!Array.isArray(parsed)
|
|
269
|
+
? (parsed as Record<string, unknown>)
|
|
270
|
+
: undefined;
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function localCorrupt(what: string): never {
|
|
277
|
+
throw new ClientSyncError(
|
|
278
|
+
'sync.local_corrupt',
|
|
279
|
+
`persisted ${what} is invalid`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Descriptor (D1)
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const COLUMN_TYPES = new Set<RowColumn['type']>([
|
|
288
|
+
'string',
|
|
289
|
+
'integer',
|
|
290
|
+
'float',
|
|
291
|
+
'boolean',
|
|
292
|
+
'bytes',
|
|
293
|
+
'json',
|
|
294
|
+
'blob_ref',
|
|
295
|
+
'crdt',
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
function decodeColumns(value: unknown): LocalSchemaDescriptorColumn[] {
|
|
299
|
+
if (!Array.isArray(value)) return localCorrupt('schema descriptor');
|
|
300
|
+
return value.map((entry) => {
|
|
301
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
302
|
+
return localCorrupt('schema descriptor');
|
|
303
|
+
}
|
|
304
|
+
const column = entry as Record<string, unknown>;
|
|
305
|
+
const keys = Object.keys(column).sort();
|
|
306
|
+
if (
|
|
307
|
+
keys.length !== 2 ||
|
|
308
|
+
keys[0] !== 'name' ||
|
|
309
|
+
keys[1] !== 'type' ||
|
|
310
|
+
typeof column.name !== 'string' ||
|
|
311
|
+
typeof column.type !== 'string' ||
|
|
312
|
+
!COLUMN_TYPES.has(column.type as RowColumn['type'])
|
|
313
|
+
) {
|
|
314
|
+
return localCorrupt('schema descriptor');
|
|
315
|
+
}
|
|
316
|
+
return { name: column.name, type: column.type as RowColumn['type'] };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function decodeTable(value: unknown): LocalSchemaDescriptorTable {
|
|
321
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
322
|
+
return localCorrupt('schema descriptor');
|
|
323
|
+
}
|
|
324
|
+
const table = value as Record<string, unknown>;
|
|
325
|
+
const keys = Object.keys(table).sort();
|
|
326
|
+
if (
|
|
327
|
+
keys.length !== 3 ||
|
|
328
|
+
keys[0] !== 'columns' ||
|
|
329
|
+
keys[1] !== 'name' ||
|
|
330
|
+
keys[2] !== 'primaryKey' ||
|
|
331
|
+
typeof table.name !== 'string' ||
|
|
332
|
+
typeof table.primaryKey !== 'string'
|
|
333
|
+
) {
|
|
334
|
+
return localCorrupt('schema descriptor');
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
name: table.name,
|
|
338
|
+
primaryKey: table.primaryKey,
|
|
339
|
+
columns: decodeColumns(table.columns),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* D1: the semantic local types of every table in the running generated schema,
|
|
345
|
+
* built from {@link localColumnType} — never from SQLite affinity.
|
|
346
|
+
*/
|
|
347
|
+
function buildLocalSchemaDescriptor(
|
|
348
|
+
schema: CompiledClientSchema,
|
|
349
|
+
): LocalSchemaDescriptor {
|
|
350
|
+
return {
|
|
351
|
+
v: DESCRIPTOR_VERSION,
|
|
352
|
+
version: schema.version,
|
|
353
|
+
tables: [...schema.tables.values()].map((table) => ({
|
|
354
|
+
name: table.name,
|
|
355
|
+
primaryKey: table.primaryKey,
|
|
356
|
+
columns: table.columns.map((column) => ({
|
|
357
|
+
name: column.name,
|
|
358
|
+
type: localColumnType(column),
|
|
359
|
+
})),
|
|
360
|
+
})),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Strict decode: an unknown shape is corruption, never a best guess. */
|
|
365
|
+
function decodeLocalSchemaDescriptor(value: string): LocalSchemaDescriptor {
|
|
366
|
+
const record = parseJsonObject(value);
|
|
367
|
+
if (record === undefined) return localCorrupt('schema descriptor');
|
|
368
|
+
const keys = Object.keys(record).sort();
|
|
369
|
+
if (
|
|
370
|
+
keys.length !== 3 ||
|
|
371
|
+
keys[0] !== 'tables' ||
|
|
372
|
+
keys[1] !== 'v' ||
|
|
373
|
+
keys[2] !== 'version' ||
|
|
374
|
+
record.v !== DESCRIPTOR_VERSION ||
|
|
375
|
+
!isCount(record.version) ||
|
|
376
|
+
!Array.isArray(record.tables)
|
|
377
|
+
) {
|
|
378
|
+
return localCorrupt('schema descriptor');
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
v: 1,
|
|
382
|
+
version: record.version,
|
|
383
|
+
tables: record.tables.map(decodeTable),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* D1: persist the descriptor. Callers MUST write this in the same transaction
|
|
389
|
+
* as {@link LOCAL_SCHEMA_VERSION_KEY}; a descriptor without a matching marker
|
|
390
|
+
* is treated as absent at reset time.
|
|
391
|
+
*/
|
|
392
|
+
export function setLocalSchemaDescriptor(
|
|
393
|
+
db: ClientDatabase,
|
|
394
|
+
schema: CompiledClientSchema,
|
|
395
|
+
): void {
|
|
396
|
+
setMeta(
|
|
397
|
+
db,
|
|
398
|
+
LOCAL_SCHEMA_DESCRIPTOR_KEY,
|
|
399
|
+
JSON.stringify(buildLocalSchemaDescriptor(schema)),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Load and strictly decode the stored descriptor; `undefined` when absent or corrupt. */
|
|
404
|
+
export function loadLocalSchemaDescriptor(
|
|
405
|
+
db: ClientDatabase,
|
|
406
|
+
): LocalSchemaDescriptor | undefined {
|
|
407
|
+
const raw = getMeta(db, LOCAL_SCHEMA_DESCRIPTOR_KEY);
|
|
408
|
+
if (raw === undefined) return undefined;
|
|
409
|
+
try {
|
|
410
|
+
return decodeLocalSchemaDescriptor(raw);
|
|
411
|
+
} catch {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Container file (D3, moved off the replica connection)
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
/** Drop both container tables. Also the unconditional orphan sweep (D5). */
|
|
421
|
+
export function dropPreviousVersionContainer(db: ClientDatabase): void {
|
|
422
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(CONTAINER_TABLE)}`);
|
|
423
|
+
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(CONTAINER_META_TABLE)}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run `fn` against the container file for exactly one open window, closing the
|
|
428
|
+
* handle afterwards. The container is only opened where `siblingExists` already
|
|
429
|
+
* reports it — probing never creates the file. `removeFile` deletes the file
|
|
430
|
+
* once the handle is closed (a sweep or a discard must leave none). Returns
|
|
431
|
+
* `undefined` when the file or the host's sibling capability is absent.
|
|
432
|
+
*/
|
|
433
|
+
export function withPreviousVersionContainer<T>(
|
|
434
|
+
db: ClientDatabase,
|
|
435
|
+
fn: (container: ClientDatabase) => T,
|
|
436
|
+
removeFile = false,
|
|
437
|
+
): T | undefined {
|
|
438
|
+
if (db.siblingExists?.(PREVIOUS_VERSION_CONTAINER_NAME) !== true) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
const handle = db.openSibling?.(PREVIOUS_VERSION_CONTAINER_NAME);
|
|
442
|
+
if (handle === undefined) return undefined;
|
|
443
|
+
try {
|
|
444
|
+
return fn(handle.database);
|
|
445
|
+
} finally {
|
|
446
|
+
handle.close();
|
|
447
|
+
if (removeFile) handle.removeFile();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Read the container's own metadata record; `undefined` when absent/corrupt. */
|
|
452
|
+
export function readPreviousVersionContainer(
|
|
453
|
+
db: ClientDatabase,
|
|
454
|
+
): PreviousVersionRecord | undefined {
|
|
455
|
+
const meta = db.query(
|
|
456
|
+
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
457
|
+
[CONTAINER_META_TABLE],
|
|
458
|
+
);
|
|
459
|
+
if (meta.length === 0) return undefined;
|
|
460
|
+
const raw = db.query(
|
|
461
|
+
`SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id = 1`,
|
|
462
|
+
)[0]?.record;
|
|
463
|
+
if (raw === undefined) return undefined;
|
|
464
|
+
try {
|
|
465
|
+
return decodeRecord(String(raw));
|
|
466
|
+
} catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function decodeRecord(value: string): PreviousVersionRecord {
|
|
472
|
+
const record = parseJsonObject(value);
|
|
473
|
+
if (record === undefined) return localCorrupt('previous-version context');
|
|
474
|
+
const keys = Object.keys(record).sort();
|
|
475
|
+
if (
|
|
476
|
+
keys.length !== 7 ||
|
|
477
|
+
keys[0] !== 'bytes' ||
|
|
478
|
+
keys[1] !== 'createdAtMs' ||
|
|
479
|
+
keys[2] !== 'currentVersion' ||
|
|
480
|
+
keys[3] !== 'previousVersion' ||
|
|
481
|
+
keys[4] !== 'rows' ||
|
|
482
|
+
keys[5] !== 'tables' ||
|
|
483
|
+
keys[6] !== 'v' ||
|
|
484
|
+
record.v !== 1 ||
|
|
485
|
+
!isCount(record.bytes) ||
|
|
486
|
+
!isCount(record.createdAtMs) ||
|
|
487
|
+
!isCount(record.currentVersion) ||
|
|
488
|
+
!isCount(record.previousVersion) ||
|
|
489
|
+
!isCount(record.rows) ||
|
|
490
|
+
!Array.isArray(record.tables)
|
|
491
|
+
) {
|
|
492
|
+
return localCorrupt('previous-version context');
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
v: 1,
|
|
496
|
+
previousVersion: record.previousVersion,
|
|
497
|
+
currentVersion: record.currentVersion,
|
|
498
|
+
tables: record.tables.map(decodeTable),
|
|
499
|
+
rows: record.rows,
|
|
500
|
+
bytes: record.bytes,
|
|
501
|
+
createdAtMs: record.createdAtMs,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function writeRecord(db: ClientDatabase, record: PreviousVersionRecord): void {
|
|
506
|
+
db.exec(
|
|
507
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
|
|
508
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
509
|
+
record TEXT NOT NULL)`,
|
|
510
|
+
);
|
|
511
|
+
db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
|
|
512
|
+
db.exec(
|
|
513
|
+
`INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id, record) VALUES (1, ?)`,
|
|
514
|
+
[JSON.stringify(record)],
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Bounded capture (D2 / A4)
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* D2/A4: measure the capture BEFORE materializing anything. Ordered probes,
|
|
524
|
+
* aborting on the first violation:
|
|
525
|
+
*
|
|
526
|
+
* 1. table count from `sqlite_master`;
|
|
527
|
+
* 2. per-table row count with a `LIMIT maxRows + 1` early-abort probe;
|
|
528
|
+
* 3. a single-row probe for rows larger than `maxRowBytes`;
|
|
529
|
+
* 4. a bounded `SUM` over a `LIMIT`ed subquery;
|
|
530
|
+
* 5. the copy itself into the CONTAINER file, in {@link COPY_BATCH_ROWS}
|
|
531
|
+
* batches and one container transaction.
|
|
532
|
+
*/
|
|
533
|
+
export function capturePreviousVersion(
|
|
534
|
+
replica: ClientDatabase,
|
|
535
|
+
container: ClientDatabase,
|
|
536
|
+
oldDescriptor: LocalSchemaDescriptor,
|
|
537
|
+
newSchema: CompiledClientSchema,
|
|
538
|
+
config: PreviousVersionCaptureConfig,
|
|
539
|
+
nowMs: number,
|
|
540
|
+
): PreviousVersionCaptureOutcome {
|
|
541
|
+
const discovered = new Set(
|
|
542
|
+
replica
|
|
543
|
+
.query(
|
|
544
|
+
`SELECT name FROM sqlite_master WHERE type = 'table'
|
|
545
|
+
AND name NOT LIKE '_syncular_%'
|
|
546
|
+
AND name NOT LIKE 'sqlite_%'`,
|
|
547
|
+
)
|
|
548
|
+
.map((row) => String(row.name)),
|
|
549
|
+
);
|
|
550
|
+
// Only descriptor-known tables carry semantic types; those are the tables the
|
|
551
|
+
// copy would hold, and the count the table budget bounds.
|
|
552
|
+
const tables = oldDescriptor.tables.filter((table) =>
|
|
553
|
+
discovered.has(table.name),
|
|
554
|
+
);
|
|
555
|
+
const over = (
|
|
556
|
+
rows: number,
|
|
557
|
+
bytes: number,
|
|
558
|
+
): PreviousVersionCaptureOutcome => ({
|
|
559
|
+
ok: false,
|
|
560
|
+
reason: 'capture-exceeded-budget',
|
|
561
|
+
measurement: { tables: tables.length, rows, bytes },
|
|
562
|
+
});
|
|
563
|
+
if (tables.length > config.maxTables) return over(0, 0);
|
|
564
|
+
|
|
565
|
+
let measuredRows = 0;
|
|
566
|
+
const perTableRows: number[] = [];
|
|
567
|
+
for (const table of tables) {
|
|
568
|
+
const probeRows = Number(
|
|
569
|
+
replica.query(
|
|
570
|
+
`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`,
|
|
571
|
+
[config.maxRows + 1],
|
|
572
|
+
)[0]?.count ?? 0,
|
|
573
|
+
);
|
|
574
|
+
measuredRows += probeRows;
|
|
575
|
+
perTableRows.push(probeRows);
|
|
576
|
+
if (measuredRows > config.maxRows) return over(measuredRows, 0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let measuredBytes = 0;
|
|
580
|
+
for (let index = 0; index < tables.length; index++) {
|
|
581
|
+
const table = tables[index] as LocalSchemaDescriptorTable;
|
|
582
|
+
const byteSum = table.columns
|
|
583
|
+
.map((c) => `COALESCE(LENGTH(CAST(${quoteIdent(c.name)} AS BLOB)), 0)`)
|
|
584
|
+
.join(' + ');
|
|
585
|
+
if (table.columns.length === 0) continue;
|
|
586
|
+
const oversized = replica.query(
|
|
587
|
+
`SELECT 1 AS hit FROM ${quoteIdent(table.name)}
|
|
588
|
+
WHERE (${byteSum}) > ? LIMIT 1`,
|
|
589
|
+
[config.maxRowBytes],
|
|
590
|
+
);
|
|
591
|
+
if (oversized.length > 0) return over(measuredRows, measuredBytes);
|
|
592
|
+
const total = replica.query(
|
|
593
|
+
`SELECT COALESCE(SUM(bytes), 0) AS total FROM (
|
|
594
|
+
SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
|
|
595
|
+
)`,
|
|
596
|
+
[perTableRows[index] ?? 0],
|
|
597
|
+
)[0]?.total;
|
|
598
|
+
measuredBytes += Number(total ?? 0);
|
|
599
|
+
if (measuredBytes > config.maxBytes)
|
|
600
|
+
return over(measuredRows, measuredBytes);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let copiedRows = 0;
|
|
604
|
+
container.transaction(() => {
|
|
605
|
+
dropPreviousVersionContainer(container);
|
|
606
|
+
container.exec(
|
|
607
|
+
`CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
|
|
608
|
+
tbl TEXT NOT NULL,
|
|
609
|
+
row_id TEXT NOT NULL,
|
|
610
|
+
payload TEXT NOT NULL,
|
|
611
|
+
PRIMARY KEY (tbl, row_id))`,
|
|
612
|
+
);
|
|
613
|
+
for (const table of tables) {
|
|
614
|
+
const selectColumns = table.columns
|
|
615
|
+
.map((column) => quoteIdent(column.name))
|
|
616
|
+
.join(', ');
|
|
617
|
+
let lastRowId = -1;
|
|
618
|
+
for (;;) {
|
|
619
|
+
const rows = replica.query(
|
|
620
|
+
`SELECT rowid AS _rid, ${selectColumns} FROM ${quoteIdent(table.name)}
|
|
621
|
+
WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
|
|
622
|
+
[lastRowId, COPY_BATCH_ROWS],
|
|
623
|
+
);
|
|
624
|
+
if (rows.length === 0) break;
|
|
625
|
+
for (const row of rows) {
|
|
626
|
+
const payload: Record<string, JsonRowValue> = {};
|
|
627
|
+
for (const column of table.columns) {
|
|
628
|
+
const value: RowValue = fromSqlValue(
|
|
629
|
+
{ name: column.name, type: column.type, nullable: true },
|
|
630
|
+
(row[column.name] ?? null) as SqlValue,
|
|
631
|
+
);
|
|
632
|
+
payload[column.name] = rowValueToJson(value);
|
|
633
|
+
}
|
|
634
|
+
container.exec(
|
|
635
|
+
`INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl, row_id, payload)
|
|
636
|
+
VALUES (?, ?, ?)`,
|
|
637
|
+
[table.name, rowIdText(table, row), JSON.stringify(payload)],
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
lastRowId = Number(rows[rows.length - 1]?._rid ?? lastRowId);
|
|
641
|
+
copiedRows += rows.length;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
writeRecord(container, {
|
|
645
|
+
v: 1,
|
|
646
|
+
previousVersion: oldDescriptor.version,
|
|
647
|
+
currentVersion: newSchema.version,
|
|
648
|
+
tables: tables.map((table) => ({
|
|
649
|
+
name: table.name,
|
|
650
|
+
primaryKey: table.primaryKey,
|
|
651
|
+
columns: table.columns,
|
|
652
|
+
})),
|
|
653
|
+
rows: copiedRows,
|
|
654
|
+
bytes: measuredBytes,
|
|
655
|
+
createdAtMs: nowMs,
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
ok: true,
|
|
660
|
+
measurement: {
|
|
661
|
+
tables: tables.length,
|
|
662
|
+
rows: copiedRows,
|
|
663
|
+
bytes: measuredBytes,
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function rowIdText(table: LocalSchemaDescriptorTable, row: SqlRow): string {
|
|
669
|
+
const value = row[table.primaryKey] ?? null;
|
|
670
|
+
if (value === null) return '';
|
|
671
|
+
if (value instanceof Uint8Array) {
|
|
672
|
+
return [...value]
|
|
673
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
674
|
+
.join('');
|
|
675
|
+
}
|
|
676
|
+
return String(value);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function decodePreviousVersionPayload(
|
|
680
|
+
table: LocalSchemaDescriptorTable,
|
|
681
|
+
payload: string,
|
|
682
|
+
): SqlRow {
|
|
683
|
+
const record = parseJsonObject(payload);
|
|
684
|
+
if (record === undefined) return localCorrupt('previous-version payload');
|
|
685
|
+
const row: SqlRow = {};
|
|
686
|
+
for (const column of table.columns) {
|
|
687
|
+
row[column.name] = jsonToRowValue(
|
|
688
|
+
(record[column.name] ?? null) as JsonRowValue,
|
|
689
|
+
) as SqlValue;
|
|
690
|
+
}
|
|
691
|
+
return row;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** D7: read one previous table from the container, `limit + 1` rows for truncation. */
|
|
695
|
+
export function readPreviousVersionRows(
|
|
696
|
+
db: ClientDatabase,
|
|
697
|
+
table: LocalSchemaDescriptorTable,
|
|
698
|
+
rowIds: readonly string[],
|
|
699
|
+
limit: number,
|
|
700
|
+
): { rows: SqlRow[]; truncated: boolean } {
|
|
701
|
+
const where =
|
|
702
|
+
rowIds.length === 0
|
|
703
|
+
? ''
|
|
704
|
+
: ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
|
|
705
|
+
const rows = db.query(
|
|
706
|
+
`SELECT row_id, payload FROM ${quoteIdent(CONTAINER_TABLE)}
|
|
707
|
+
WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`,
|
|
708
|
+
[table.name, ...rowIds, limit + 1],
|
|
709
|
+
);
|
|
710
|
+
const truncated = rows.length > limit;
|
|
711
|
+
const visible = truncated ? rows.slice(0, limit) : rows;
|
|
712
|
+
return {
|
|
713
|
+
rows: visible.map((row) =>
|
|
714
|
+
decodePreviousVersionPayload(table, String(row.payload)),
|
|
715
|
+
),
|
|
716
|
+
truncated,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// Durable refusal (read surface for a capture that stored nothing)
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
|
|
724
|
+
export function writePreviousVersionRefusal(
|
|
725
|
+
db: ClientDatabase,
|
|
726
|
+
reason: PreviousVersionRefusalReason,
|
|
727
|
+
measurement: PreviousVersionCaptureMeasurement,
|
|
728
|
+
): void {
|
|
729
|
+
const refusal: PreviousVersionRefusal = {
|
|
730
|
+
v: 1,
|
|
731
|
+
reason,
|
|
732
|
+
tables: measurement.tables,
|
|
733
|
+
rows: measurement.rows,
|
|
734
|
+
bytes: measurement.bytes,
|
|
735
|
+
};
|
|
736
|
+
setMeta(db, PREVIOUS_VERSION_CONTEXT_KEY, JSON.stringify(refusal));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
|
|
740
|
+
export function clearPreviousVersionRefusal(db: ClientDatabase): void {
|
|
741
|
+
db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
|
|
742
|
+
PREVIOUS_VERSION_CONTEXT_KEY,
|
|
743
|
+
]);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Decode the durable refusal; `undefined` when absent or corrupt. */
|
|
747
|
+
export function storedPreviousVersionRefusal(
|
|
748
|
+
db: ClientDatabase,
|
|
749
|
+
): PreviousVersionRefusal | undefined {
|
|
750
|
+
const raw = getMeta(db, PREVIOUS_VERSION_CONTEXT_KEY);
|
|
751
|
+
if (raw === undefined) return undefined;
|
|
752
|
+
const record = parseJsonObject(raw);
|
|
753
|
+
if (record === undefined) return undefined;
|
|
754
|
+
if (
|
|
755
|
+
record.v !== 1 ||
|
|
756
|
+
(record.reason !== 'no-previous-descriptor' &&
|
|
757
|
+
record.reason !== 'capture-exceeded-budget')
|
|
758
|
+
) {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
v: 1,
|
|
763
|
+
reason: record.reason,
|
|
764
|
+
...(isCount(record.tables) ? { tables: record.tables } : {}),
|
|
765
|
+
...(isCount(record.rows) ? { rows: record.rows } : {}),
|
|
766
|
+
...(isCount(record.bytes) ? { bytes: record.bytes } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// Compatibility audit (D6)
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
interface PendingCommitLike {
|
|
775
|
+
readonly clientCommitId: string;
|
|
776
|
+
readonly operations: readonly {
|
|
777
|
+
readonly table: string;
|
|
778
|
+
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
779
|
+
}[];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* D6: classify pending outbox commits against the NEW compiled schema, before
|
|
784
|
+
* the wipe. It records the commit id, table, typed reason and offending column
|
|
785
|
+
* ONLY — never an operation, row value or envelope field. The envelope stays in
|
|
786
|
+
* the outbox (and, on a terminal drop, in the §7.2.1 journal).
|
|
787
|
+
*/
|
|
788
|
+
export function buildPreviousVersionAudit(
|
|
789
|
+
schema: CompiledClientSchema,
|
|
790
|
+
pending: readonly PendingCommitLike[],
|
|
791
|
+
fromVersion: number,
|
|
792
|
+
toVersion: number,
|
|
793
|
+
atMs: number,
|
|
794
|
+
): PreviousVersionAudit {
|
|
795
|
+
const incompatible: PreviousVersionAuditEntry[] = [];
|
|
796
|
+
let incompatibleTotal = 0;
|
|
797
|
+
let encodable = 0;
|
|
798
|
+
for (const commit of pending) {
|
|
799
|
+
const entry = firstIncompatibility(schema, commit);
|
|
800
|
+
if (entry === undefined) {
|
|
801
|
+
encodable += 1;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
incompatibleTotal += 1;
|
|
805
|
+
if (incompatible.length < MAX_AUDIT_INCOMPATIBLE) incompatible.push(entry);
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
v: AUDIT_VERSION,
|
|
809
|
+
atMs,
|
|
810
|
+
fromVersion,
|
|
811
|
+
toVersion,
|
|
812
|
+
pending: pending.length,
|
|
813
|
+
encodable,
|
|
814
|
+
truncated: incompatibleTotal > MAX_AUDIT_INCOMPATIBLE,
|
|
815
|
+
incompatible,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function firstIncompatibility(
|
|
820
|
+
schema: CompiledClientSchema,
|
|
821
|
+
commit: PendingCommitLike,
|
|
822
|
+
): PreviousVersionAuditEntry | undefined {
|
|
823
|
+
for (const operation of commit.operations) {
|
|
824
|
+
const table = schema.tables.get(operation.table);
|
|
825
|
+
if (table === undefined) {
|
|
826
|
+
return {
|
|
827
|
+
commitId: commit.clientCommitId,
|
|
828
|
+
table: operation.table,
|
|
829
|
+
reason: 'unknown-table',
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
if (operation.values === undefined) continue;
|
|
833
|
+
for (const key of Object.keys(operation.values)) {
|
|
834
|
+
if (!table.columnIndex.has(key)) {
|
|
835
|
+
return {
|
|
836
|
+
commitId: commit.clientCommitId,
|
|
837
|
+
table: operation.table,
|
|
838
|
+
reason: 'unknown-column',
|
|
839
|
+
column: key,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Strict decode; `undefined` when absent or malformed. */
|
|
848
|
+
export function storedPreviousVersionAudit(
|
|
849
|
+
db: ClientDatabase,
|
|
850
|
+
): PreviousVersionAudit | undefined {
|
|
851
|
+
const raw = getMeta(db, PREVIOUS_VERSION_AUDIT_KEY);
|
|
852
|
+
if (raw === undefined) return undefined;
|
|
853
|
+
const record = parseJsonObject(raw);
|
|
854
|
+
if (record === undefined) return undefined;
|
|
855
|
+
if (
|
|
856
|
+
record.v !== AUDIT_VERSION ||
|
|
857
|
+
!isCount(record.atMs) ||
|
|
858
|
+
!isCount(record.fromVersion) ||
|
|
859
|
+
!isCount(record.toVersion) ||
|
|
860
|
+
!isCount(record.pending) ||
|
|
861
|
+
!isCount(record.encodable) ||
|
|
862
|
+
typeof record.truncated !== 'boolean' ||
|
|
863
|
+
!Array.isArray(record.incompatible)
|
|
864
|
+
) {
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
const incompatible: PreviousVersionAuditEntry[] = [];
|
|
868
|
+
for (const entry of record.incompatible) {
|
|
869
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
870
|
+
return undefined;
|
|
871
|
+
}
|
|
872
|
+
const item = entry as Record<string, unknown>;
|
|
873
|
+
if (
|
|
874
|
+
typeof item.commitId !== 'string' ||
|
|
875
|
+
typeof item.table !== 'string' ||
|
|
876
|
+
(item.reason !== 'unknown-table' && item.reason !== 'unknown-column') ||
|
|
877
|
+
(item.column !== undefined && typeof item.column !== 'string')
|
|
878
|
+
) {
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
incompatible.push({
|
|
882
|
+
commitId: item.commitId,
|
|
883
|
+
table: item.table,
|
|
884
|
+
reason: item.reason,
|
|
885
|
+
...(typeof item.column === 'string' ? { column: item.column } : {}),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
v: 1,
|
|
890
|
+
atMs: record.atMs,
|
|
891
|
+
fromVersion: record.fromVersion,
|
|
892
|
+
toVersion: record.toVersion,
|
|
893
|
+
pending: record.pending,
|
|
894
|
+
encodable: record.encodable,
|
|
895
|
+
truncated: record.truncated,
|
|
896
|
+
incompatible,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function writePreviousVersionAudit(
|
|
901
|
+
db: ClientDatabase,
|
|
902
|
+
audit: PreviousVersionAudit,
|
|
903
|
+
): void {
|
|
904
|
+
setMeta(db, PREVIOUS_VERSION_AUDIT_KEY, JSON.stringify(audit));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Delete the advisory audit. Part of a discard, never of a normal bump. */
|
|
908
|
+
export function clearPreviousVersionAudit(db: ClientDatabase): void {
|
|
909
|
+
db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
|
|
910
|
+
PREVIOUS_VERSION_AUDIT_KEY,
|
|
911
|
+
]);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Strict bridge decoders (RFC 0005 D10)
|
|
916
|
+
//
|
|
917
|
+
// Compile-time host types are not runtime proof. A version-drifted Worker or
|
|
918
|
+
// native bridge could answer a read command with a forged `available: true`
|
|
919
|
+
// and a peer's protected rows, so every reply is validated against the closed
|
|
920
|
+
// public shape before an application can observe it.
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
/** Strictly decode a Worker/native `previousVersionSnapshot` reply. */
|
|
924
|
+
export function decodePreviousVersionSnapshot(
|
|
925
|
+
value: unknown,
|
|
926
|
+
): PreviousVersionSnapshot {
|
|
927
|
+
const method = 'previousVersionSnapshot';
|
|
928
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
929
|
+
return invalidHostResponse(method);
|
|
930
|
+
}
|
|
931
|
+
const source = value as Record<string, unknown>;
|
|
932
|
+
const allowed = new Set<string>([
|
|
933
|
+
...SNAPSHOT_REQUIRED_KEYS,
|
|
934
|
+
...SNAPSHOT_OPTIONAL_KEYS,
|
|
935
|
+
]);
|
|
936
|
+
const keys = Object.keys(source);
|
|
937
|
+
if (
|
|
938
|
+
!keys.every((key) => allowed.has(key)) ||
|
|
939
|
+
SNAPSHOT_REQUIRED_KEYS.some((key) => !(key in source)) ||
|
|
940
|
+
source.state !== 'previousVersion' ||
|
|
941
|
+
typeof source.available !== 'boolean' ||
|
|
942
|
+
!isCount(source.currentVersion) ||
|
|
943
|
+
typeof source.truncated !== 'boolean' ||
|
|
944
|
+
!Array.isArray(source.rows) ||
|
|
945
|
+
!source.rows.every(isSqlRow)
|
|
946
|
+
) {
|
|
947
|
+
return invalidHostResponse(method);
|
|
948
|
+
}
|
|
949
|
+
const reason = source.reason;
|
|
950
|
+
const previousVersion = source.previousVersion;
|
|
951
|
+
const rows = source.rows as SqlRow[];
|
|
952
|
+
if (source.available) {
|
|
953
|
+
// An available read names the previous version and never a refusal.
|
|
954
|
+
if (!isCount(previousVersion) || reason !== undefined) {
|
|
955
|
+
return invalidHostResponse(method);
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
state: 'previousVersion',
|
|
959
|
+
available: true,
|
|
960
|
+
previousVersion,
|
|
961
|
+
currentVersion: source.currentVersion,
|
|
962
|
+
rows,
|
|
963
|
+
truncated: source.truncated,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (
|
|
967
|
+
previousVersion !== undefined ||
|
|
968
|
+
reason === undefined ||
|
|
969
|
+
!PREVIOUS_VERSION_REASONS.includes(reason as PreviousVersionReason)
|
|
970
|
+
) {
|
|
971
|
+
return invalidHostResponse(method);
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
state: 'previousVersion',
|
|
975
|
+
available: false,
|
|
976
|
+
currentVersion: source.currentVersion,
|
|
977
|
+
reason: reason as PreviousVersionReason,
|
|
978
|
+
rows,
|
|
979
|
+
truncated: source.truncated,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Strictly decode a Worker/native `previousVersionAudit` reply. `undefined` is
|
|
985
|
+
* the legitimate "no audit recorded" answer; anything else malformed throws.
|
|
986
|
+
*/
|
|
987
|
+
export function decodePreviousVersionAuditResult(
|
|
988
|
+
value: unknown,
|
|
989
|
+
): PreviousVersionAudit | undefined {
|
|
990
|
+
const method = 'previousVersionAudit';
|
|
991
|
+
if (value === undefined) return undefined;
|
|
992
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
993
|
+
return invalidHostResponse(method);
|
|
994
|
+
}
|
|
995
|
+
const source = value as Record<string, unknown>;
|
|
996
|
+
const keys = Object.keys(source).sort();
|
|
997
|
+
if (keys.length !== AUDIT_KEYS.length) return invalidHostResponse(method);
|
|
998
|
+
if (keys.some((key, index) => key !== AUDIT_KEYS[index])) {
|
|
999
|
+
return invalidHostResponse(method);
|
|
1000
|
+
}
|
|
1001
|
+
if (
|
|
1002
|
+
source.v !== AUDIT_VERSION ||
|
|
1003
|
+
!isCount(source.atMs) ||
|
|
1004
|
+
!isCount(source.fromVersion) ||
|
|
1005
|
+
!isCount(source.toVersion) ||
|
|
1006
|
+
!isCount(source.pending) ||
|
|
1007
|
+
!isCount(source.encodable) ||
|
|
1008
|
+
typeof source.truncated !== 'boolean' ||
|
|
1009
|
+
!Array.isArray(source.incompatible) ||
|
|
1010
|
+
source.incompatible.length > MAX_AUDIT_INCOMPATIBLE
|
|
1011
|
+
) {
|
|
1012
|
+
return invalidHostResponse(method);
|
|
1013
|
+
}
|
|
1014
|
+
const incompatible: PreviousVersionAuditEntry[] = [];
|
|
1015
|
+
for (const entry of source.incompatible) {
|
|
1016
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1017
|
+
return invalidHostResponse(method);
|
|
1018
|
+
}
|
|
1019
|
+
const item = entry as Record<string, unknown>;
|
|
1020
|
+
if (
|
|
1021
|
+
!Object.keys(item).every((key) =>
|
|
1022
|
+
(AUDIT_ENTRY_KEYS as readonly string[]).includes(key),
|
|
1023
|
+
) ||
|
|
1024
|
+
typeof item.commitId !== 'string' ||
|
|
1025
|
+
typeof item.table !== 'string' ||
|
|
1026
|
+
(item.reason !== 'unknown-table' && item.reason !== 'unknown-column') ||
|
|
1027
|
+
(item.column !== undefined && typeof item.column !== 'string')
|
|
1028
|
+
) {
|
|
1029
|
+
return invalidHostResponse(method);
|
|
1030
|
+
}
|
|
1031
|
+
incompatible.push({
|
|
1032
|
+
commitId: item.commitId,
|
|
1033
|
+
table: item.table,
|
|
1034
|
+
reason: item.reason,
|
|
1035
|
+
...(typeof item.column === 'string' ? { column: item.column } : {}),
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
v: 1,
|
|
1040
|
+
atMs: source.atMs,
|
|
1041
|
+
fromVersion: source.fromVersion,
|
|
1042
|
+
toVersion: source.toVersion,
|
|
1043
|
+
pending: source.pending,
|
|
1044
|
+
encodable: source.encodable,
|
|
1045
|
+
truncated: source.truncated,
|
|
1046
|
+
incompatible,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Strictly decode a Worker/native `previousVersionDiscard` reply. */
|
|
1051
|
+
export function decodePreviousVersionDiscardResult(value: unknown): {
|
|
1052
|
+
readonly present: boolean;
|
|
1053
|
+
readonly discarded: boolean;
|
|
1054
|
+
} {
|
|
1055
|
+
const method = 'previousVersionDiscard';
|
|
1056
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1057
|
+
return invalidHostResponse(method);
|
|
1058
|
+
}
|
|
1059
|
+
const source = value as Record<string, unknown>;
|
|
1060
|
+
const keys = Object.keys(source).sort();
|
|
1061
|
+
if (
|
|
1062
|
+
keys.length !== DISCARD_KEYS.length ||
|
|
1063
|
+
keys.some((key, index) => key !== DISCARD_KEYS[index]) ||
|
|
1064
|
+
typeof source.present !== 'boolean' ||
|
|
1065
|
+
typeof source.discarded !== 'boolean'
|
|
1066
|
+
) {
|
|
1067
|
+
return invalidHostResponse(method);
|
|
1068
|
+
}
|
|
1069
|
+
return { present: source.present, discarded: source.discarded };
|
|
1070
|
+
}
|