@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.
@@ -3,7 +3,7 @@
3
3
  * adapter: synchronous queries, nested transactions, and SQLite image attach.
4
4
  */
5
5
  import { DatabaseSync } from 'node:sqlite';
6
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { assertImageAlias, runTransaction, } from './database.js';
@@ -29,8 +29,10 @@ function normalizeRow(row) {
29
29
  }
30
30
  export class NodeClientDatabase {
31
31
  db;
32
+ #path;
32
33
  #tx = { depth: 0 };
33
34
  constructor(path = ':memory:') {
35
+ this.#path = path;
34
36
  this.db = new DatabaseSync(path);
35
37
  try {
36
38
  this.db.exec('PRAGMA journal_mode = WAL');
@@ -48,6 +50,27 @@ export class NodeClientDatabase {
48
50
  const rows = this.db.prepare(sql).all(...coerceParams(params));
49
51
  return rows.map(normalizeRow);
50
52
  }
53
+ /** RFC 0005: a sibling file beside the replica, e.g. `<path>.prev-context`. */
54
+ openSibling(name) {
55
+ const siblingPath = this.#path === ':memory:' ? ':memory:' : `${this.#path}.${name}`;
56
+ const database = new NodeClientDatabase(siblingPath);
57
+ return {
58
+ database,
59
+ close: () => database.close(),
60
+ removeFile: () => {
61
+ if (siblingPath === ':memory:')
62
+ return;
63
+ rmSync(siblingPath, { force: true });
64
+ rmSync(`${siblingPath}-wal`, { force: true });
65
+ rmSync(`${siblingPath}-shm`, { force: true });
66
+ },
67
+ };
68
+ }
69
+ siblingExists(name) {
70
+ if (this.#path === ':memory:')
71
+ return false;
72
+ return existsSync(`${this.#path}.${name}`);
73
+ }
51
74
  transaction(fn) {
52
75
  return runTransaction(this.#tx, (sql) => this.db.exec(sql), fn);
53
76
  }
@@ -0,0 +1,207 @@
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 } from '@syncular/core';
33
+ import type { ClientDatabase, SqlRow } from './database.js';
34
+ import { type CompiledClientSchema, type JsonRowValue } from './schema.js';
35
+ /**
36
+ * RFC 0005: the code-derived sibling database filename. Kept out of the replica
37
+ * as data minimisation, not as a security control.
38
+ */
39
+ export declare const PREVIOUS_VERSION_CONTAINER_NAME = "prev-context";
40
+ /** Read spec `limit` ceiling (D7). */
41
+ export declare const PREVIOUS_VERSION_MAX_LIMIT = 200;
42
+ /**
43
+ * Every reason the read surface can name. `security-inactive` is absent because
44
+ * `#requireActive()` throws before a read; `purged` and post-TTL are absent
45
+ * because a discard removes the records, so no durable state can name them;
46
+ * `namespace-collision` is absent because the container is a separate file and
47
+ * cannot collide with a replica table.
48
+ */
49
+ export type PreviousVersionReason = 'not-configured' | 'no-previous-descriptor' | 'capture-exceeded-budget' | 'coverage-complete' | 'expired' | 'lease-inactive' | 'scope-revoked';
50
+ /** Refusals that ARE durable, because no container is written for them. */
51
+ type PreviousVersionRefusalReason = 'no-previous-descriptor' | 'capture-exceeded-budget';
52
+ interface LocalSchemaDescriptorColumn {
53
+ readonly name: string;
54
+ readonly type: RowColumn['type'];
55
+ }
56
+ interface LocalSchemaDescriptorTable {
57
+ readonly name: string;
58
+ readonly primaryKey: string;
59
+ readonly columns: readonly LocalSchemaDescriptorColumn[];
60
+ }
61
+ interface LocalSchemaDescriptor {
62
+ readonly v: 1;
63
+ readonly version: number;
64
+ readonly tables: readonly LocalSchemaDescriptorTable[];
65
+ }
66
+ /** Successful capture record, stored INSIDE the container file. */
67
+ export interface PreviousVersionRecord {
68
+ readonly v: 1;
69
+ readonly previousVersion: number;
70
+ readonly currentVersion: number;
71
+ readonly tables: readonly LocalSchemaDescriptorTable[];
72
+ readonly rows: number;
73
+ readonly bytes: number;
74
+ readonly createdAtMs: number;
75
+ }
76
+ /** Durable refusal stored in `_syncular_meta` when no container is written. */
77
+ interface PreviousVersionRefusal {
78
+ readonly v: 1;
79
+ readonly reason: PreviousVersionRefusalReason;
80
+ readonly tables?: number;
81
+ readonly rows?: number;
82
+ readonly bytes?: number;
83
+ }
84
+ export interface PreviousVersionCaptureConfig {
85
+ readonly maxBytes: number;
86
+ readonly maxRows: number;
87
+ readonly maxTables: number;
88
+ readonly maxRowBytes: number;
89
+ }
90
+ interface PreviousVersionCaptureMeasurement {
91
+ readonly tables: number;
92
+ readonly rows: number;
93
+ readonly bytes: number;
94
+ }
95
+ type PreviousVersionCaptureOutcome = {
96
+ readonly ok: true;
97
+ readonly measurement: PreviousVersionCaptureMeasurement;
98
+ } | {
99
+ readonly ok: false;
100
+ readonly reason: 'capture-exceeded-budget';
101
+ readonly measurement: PreviousVersionCaptureMeasurement;
102
+ };
103
+ interface PreviousVersionAuditEntry {
104
+ readonly commitId: string;
105
+ readonly table: string;
106
+ readonly reason: 'unknown-table' | 'unknown-column';
107
+ /** The offending column; present for every reason except `unknown-table`. */
108
+ readonly column?: string;
109
+ }
110
+ export interface PreviousVersionAudit {
111
+ readonly v: 1;
112
+ readonly atMs: number;
113
+ readonly fromVersion: number;
114
+ readonly toVersion: number;
115
+ readonly pending: number;
116
+ readonly encodable: number;
117
+ readonly truncated: boolean;
118
+ readonly incompatible: readonly PreviousVersionAuditEntry[];
119
+ }
120
+ export interface PreviousVersionReadSpec {
121
+ readonly table: string;
122
+ readonly rowIds?: readonly string[];
123
+ readonly limit?: number;
124
+ }
125
+ export interface PreviousVersionSnapshot {
126
+ readonly state: 'previousVersion';
127
+ readonly available: boolean;
128
+ readonly previousVersion?: number;
129
+ readonly currentVersion: number;
130
+ readonly reason?: PreviousVersionReason;
131
+ readonly rows: readonly SqlRow[];
132
+ readonly truncated: boolean;
133
+ }
134
+ /**
135
+ * D1: persist the descriptor. Callers MUST write this in the same transaction
136
+ * as {@link LOCAL_SCHEMA_VERSION_KEY}; a descriptor without a matching marker
137
+ * is treated as absent at reset time.
138
+ */
139
+ export declare function setLocalSchemaDescriptor(db: ClientDatabase, schema: CompiledClientSchema): void;
140
+ /** Load and strictly decode the stored descriptor; `undefined` when absent or corrupt. */
141
+ export declare function loadLocalSchemaDescriptor(db: ClientDatabase): LocalSchemaDescriptor | undefined;
142
+ /** Drop both container tables. Also the unconditional orphan sweep (D5). */
143
+ export declare function dropPreviousVersionContainer(db: ClientDatabase): void;
144
+ /**
145
+ * Run `fn` against the container file for exactly one open window, closing the
146
+ * handle afterwards. The container is only opened where `siblingExists` already
147
+ * reports it — probing never creates the file. `removeFile` deletes the file
148
+ * once the handle is closed (a sweep or a discard must leave none). Returns
149
+ * `undefined` when the file or the host's sibling capability is absent.
150
+ */
151
+ export declare function withPreviousVersionContainer<T>(db: ClientDatabase, fn: (container: ClientDatabase) => T, removeFile?: boolean): T | undefined;
152
+ /** Read the container's own metadata record; `undefined` when absent/corrupt. */
153
+ export declare function readPreviousVersionContainer(db: ClientDatabase): PreviousVersionRecord | undefined;
154
+ /**
155
+ * D2/A4: measure the capture BEFORE materializing anything. Ordered probes,
156
+ * aborting on the first violation:
157
+ *
158
+ * 1. table count from `sqlite_master`;
159
+ * 2. per-table row count with a `LIMIT maxRows + 1` early-abort probe;
160
+ * 3. a single-row probe for rows larger than `maxRowBytes`;
161
+ * 4. a bounded `SUM` over a `LIMIT`ed subquery;
162
+ * 5. the copy itself into the CONTAINER file, in {@link COPY_BATCH_ROWS}
163
+ * batches and one container transaction.
164
+ */
165
+ export declare function capturePreviousVersion(replica: ClientDatabase, container: ClientDatabase, oldDescriptor: LocalSchemaDescriptor, newSchema: CompiledClientSchema, config: PreviousVersionCaptureConfig, nowMs: number): PreviousVersionCaptureOutcome;
166
+ /** D7: read one previous table from the container, `limit + 1` rows for truncation. */
167
+ export declare function readPreviousVersionRows(db: ClientDatabase, table: LocalSchemaDescriptorTable, rowIds: readonly string[], limit: number): {
168
+ rows: SqlRow[];
169
+ truncated: boolean;
170
+ };
171
+ export declare function writePreviousVersionRefusal(db: ClientDatabase, reason: PreviousVersionRefusalReason, measurement: PreviousVersionCaptureMeasurement): void;
172
+ /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
173
+ export declare function clearPreviousVersionRefusal(db: ClientDatabase): void;
174
+ /** Decode the durable refusal; `undefined` when absent or corrupt. */
175
+ export declare function storedPreviousVersionRefusal(db: ClientDatabase): PreviousVersionRefusal | undefined;
176
+ interface PendingCommitLike {
177
+ readonly clientCommitId: string;
178
+ readonly operations: readonly {
179
+ readonly table: string;
180
+ readonly values?: Readonly<Record<string, JsonRowValue>>;
181
+ }[];
182
+ }
183
+ /**
184
+ * D6: classify pending outbox commits against the NEW compiled schema, before
185
+ * the wipe. It records the commit id, table, typed reason and offending column
186
+ * ONLY — never an operation, row value or envelope field. The envelope stays in
187
+ * the outbox (and, on a terminal drop, in the §7.2.1 journal).
188
+ */
189
+ export declare function buildPreviousVersionAudit(schema: CompiledClientSchema, pending: readonly PendingCommitLike[], fromVersion: number, toVersion: number, atMs: number): PreviousVersionAudit;
190
+ /** Strict decode; `undefined` when absent or malformed. */
191
+ export declare function storedPreviousVersionAudit(db: ClientDatabase): PreviousVersionAudit | undefined;
192
+ export declare function writePreviousVersionAudit(db: ClientDatabase, audit: PreviousVersionAudit): void;
193
+ /** Delete the advisory audit. Part of a discard, never of a normal bump. */
194
+ export declare function clearPreviousVersionAudit(db: ClientDatabase): void;
195
+ /** Strictly decode a Worker/native `previousVersionSnapshot` reply. */
196
+ export declare function decodePreviousVersionSnapshot(value: unknown): PreviousVersionSnapshot;
197
+ /**
198
+ * Strictly decode a Worker/native `previousVersionAudit` reply. `undefined` is
199
+ * the legitimate "no audit recorded" answer; anything else malformed throws.
200
+ */
201
+ export declare function decodePreviousVersionAuditResult(value: unknown): PreviousVersionAudit | undefined;
202
+ /** Strictly decode a Worker/native `previousVersionDiscard` reply. */
203
+ export declare function decodePreviousVersionDiscardResult(value: unknown): {
204
+ readonly present: boolean;
205
+ readonly discarded: boolean;
206
+ };
207
+ export {};