@jarenjs/db 0.85.0 → 0.87.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,185 @@
1
+ //@ts-check
2
+ /** Shared bounded RPC Connection client for owned SQLite execution hosts. */
3
+ import { finishConnection } from '../driver.js';
4
+ import { sqliteDialect } from '../dialects/sqlite.js';
5
+ import { DbRuntimeError } from '../errors.js';
6
+ import { generationFailure, queueFailure, rowBytes, validResponse, validResult } from './worker-protocol.js';
7
+
8
+ /** @param {any} worker @param {any} settings @returns {Promise<any>} */
9
+ export async function createWorkerConnection(worker, settings) {
10
+ const { epoch, options, limits, maxPending, allRows, allBytes, closeMs, startupMs, reopen, hooks = {} } = settings;
11
+ const pending = new Map();
12
+ let sequence = 0;
13
+ let failed = null;
14
+ let closing = false;
15
+ let closed = false;
16
+ let transactionDepth = 0;
17
+ let closePromise;
18
+ const metrics = { frames: 0, rows: 0, maxFrameRows: 0, maxFrameBytes: 0, maxPending: 0 };
19
+ let readyResolve;
20
+ let readyReject;
21
+ const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
22
+ const lose = (cause) => {
23
+ if (failed !== null || closed) return;
24
+ failed = generationFailure(epoch, transactionDepth > 0, cause);
25
+ hooks.lost?.(failed, [...pending.values()]);
26
+ readyReject(failed);
27
+ for (const request of pending.values()) request.reject(failed);
28
+ pending.clear();
29
+ };
30
+ hooks.control?.({ lose });
31
+ worker.on('error', lose);
32
+ worker.on('exit', (code) => { if (!closed) lose(new Error(`worker exited (${code})`)); });
33
+ worker.on('message', (message) => {
34
+ if (!validResponse(message, epoch)) { lose(new Error('invalid worker response')); return; }
35
+ if (message.kind === 'ready') { readyResolve(message.capabilities); return; }
36
+ if (message.kind === 'failure' && message.id === 0) {
37
+ readyReject(Object.assign(new Error(message.error.message), message.error)); return;
38
+ }
39
+ const request = pending.get(message.id);
40
+ if (request === undefined) return;
41
+ pending.delete(message.id);
42
+ if (message.kind === 'failure') {
43
+ hooks.failure?.(request.op, request.data, message.transaction);
44
+ const error = message.error;
45
+ request.reject(Object.assign(error.code?.startsWith('JD')
46
+ ? new DbRuntimeError(error.code, error.message) : new Error(error.message), error));
47
+ }
48
+ else if (message.kind === 'result' && validResult(request.op, message.value, limits)) {
49
+ hooks.result?.(request.op, request.data, message.value, message.transaction); request.resolve(message.value);
50
+ }
51
+ else { request.reject(generationFailure(epoch, transactionDepth > 0)); lose(new Error('invalid worker response')); }
52
+ });
53
+ const timer = setTimeout(() => { lose(new Error('worker startup timed out')); worker.terminate(); }, startupMs);
54
+ let capabilities;
55
+ try { capabilities = await ready; }
56
+ catch (error) {
57
+ const termination = worker.terminate();
58
+ if (settings.awaitStartupExit !== false) await termination;
59
+ else termination.catch(() => {});
60
+ throw error;
61
+ }
62
+ finally { clearTimeout(timer); }
63
+ const request = (op, data = {}, cleanup = false) => {
64
+ if (failed !== null) return Promise.reject(failed);
65
+ if (closed || (closing && !cleanup)) return Promise.reject(new DbRuntimeError('JD2063', 'the worker connection is closing or closed'));
66
+ if (!cleanup && pending.size >= maxPending)
67
+ return Promise.reject(queueFailure(`worker request capacity ${maxPending} exceeded`, pending.size));
68
+ // One return per live cursor and one close have reserved capacity.
69
+ if (cleanup && pending.size >= maxPending + limits.cursors + 1)
70
+ return Promise.reject(queueFailure('worker cleanup capacity exceeded', pending.size));
71
+ const id = ++sequence;
72
+ return new Promise((resolve, reject) => {
73
+ pending.set(id, { resolve, reject, op, data });
74
+ metrics.maxPending = Math.max(metrics.maxPending, pending.size);
75
+ try { hooks.request?.(op, data); worker.postMessage({ v: 1, generation: epoch, kind: 'request', id, op, ...data }); }
76
+ catch (error) { pending.delete(id); reject(error); }
77
+ });
78
+ };
79
+ const raw = {
80
+ closeDrainsIterators: true,
81
+ exec: (sql) => {
82
+ if (/^(?:SAVEPOINT|BEGIN)\b/.test(sql)) transactionDepth++;
83
+ return request('exec', { sql }).then((value) => {
84
+ if (/^RELEASE\b/.test(sql)) transactionDepth = Math.max(0, transactionDepth - 1);
85
+ if (/^(?:COMMIT|ROLLBACK(?! TO))\b/.test(sql)) transactionDepth = 0;
86
+ return value;
87
+ });
88
+ },
89
+ prepare: async (sql, metadata = {}) => {
90
+ const id = await request('prepare', { sql, ephemeral: metadata.ephemeral === true });
91
+ const iterate = async (params = []) => {
92
+ const cursor = await request('iterate', { statement: id, params });
93
+ let rows = [];
94
+ let at = 0;
95
+ let done = false;
96
+ let returned = false;
97
+ let pulling = Promise.resolve();
98
+ const next = async () => {
99
+ if (failed !== null) throw failed;
100
+ if (returned) return { done: true, value: undefined };
101
+ if (at < rows.length) return { done: false, value: rows[at++] };
102
+ if (done) return { done: true, value: undefined };
103
+ const batch = await request('next', { cursor, rows: limits.rows, bytes: limits.bytes });
104
+ metrics.frames++;
105
+ metrics.rows += batch.rows.length;
106
+ metrics.maxFrameRows = Math.max(metrics.maxFrameRows, batch.rows.length);
107
+ metrics.maxFrameBytes = Math.max(metrics.maxFrameBytes, batch.bytes);
108
+ if (returned) return { done: true, value: undefined };
109
+ rows = batch.rows;
110
+ at = 0;
111
+ done = batch.done;
112
+ return at < rows.length ? { done: false, value: rows[at++] } : { done: true, value: undefined };
113
+ };
114
+ return {
115
+ next: () => {
116
+ const result = pulling.then(next);
117
+ pulling = result.then(() => undefined, () => undefined);
118
+ return result;
119
+ },
120
+ return: async () => {
121
+ if (returned) return { done: true, value: undefined };
122
+ returned = true;
123
+ rows = [];
124
+ if (!done) await request('return', { cursor }, true);
125
+ return { done: true, value: undefined };
126
+ },
127
+ };
128
+ };
129
+ return {
130
+ run: (params = []) => request('run', { statement: id, params }),
131
+ get: (params = []) => request('get', { statement: id, params }),
132
+ iterate,
133
+ all: async (params = []) => {
134
+ const iterator = await iterate(params);
135
+ const rows = [];
136
+ let bytes = 0;
137
+ try {
138
+ for (;;) {
139
+ const step = await iterator.next();
140
+ if (step.done) return rows;
141
+ bytes += rowBytes(step.value);
142
+ if (rows.length >= allRows || bytes > allBytes)
143
+ throw new DbRuntimeError('JD2092', `worker all() exceeds its ${allRows} row / ${allBytes} byte bound; use a cursor`);
144
+ rows.push(step.value);
145
+ }
146
+ }
147
+ finally { await iterator.return(); }
148
+ },
149
+ };
150
+ },
151
+ close: () => {
152
+ if (closePromise !== undefined) return closePromise;
153
+ closing = true;
154
+ closePromise = new Promise((resolve, reject) => {
155
+ const timeout = setTimeout(() => {
156
+ lose(new Error('worker close timed out'));
157
+ // V8 termination cannot preempt a synchronous native SQLite
158
+ // call. Fence and detach now; never hold the caller's deadline
159
+ // hostage to that native call or claim it was rolled back.
160
+ worker.unref();
161
+ worker.terminate().catch(() => {});
162
+ reject(failed);
163
+ }, closeMs);
164
+ request('close', {}, true).then(() => {
165
+ closed = true;
166
+ clearTimeout(timeout);
167
+ worker.terminate().then(() => resolve(undefined), reject);
168
+ }, (error) => {
169
+ clearTimeout(timeout);
170
+ worker.unref();
171
+ worker.terminate().catch(() => {});
172
+ reject(error);
173
+ });
174
+ });
175
+ return closePromise;
176
+ },
177
+ };
178
+ const connection = finishConnection(raw, sqliteDialect, false, Object.freeze(capabilities), options.queueTimeout);
179
+ return Object.freeze({ ...connection,
180
+ get mustQueue() { return connection.mustQueue; },
181
+ generation: epoch,
182
+ metrics: () => Object.freeze({ ...metrics, pending: pending.size, generation: epoch, healthy: failed === null && !closed }),
183
+ restart: async () => { lose(new Error('worker restarted')); await worker.terminate(); return reopen(); },
184
+ });
185
+ }
@@ -4,6 +4,11 @@ import { DbRuntimeError } from '../errors.js';
4
4
  import { jsonStringBytes } from '../json-bytes.js';
5
5
 
6
6
  export const WORKER_PROTOCOL_VERSION = 1;
7
+ export const WORKER_DEFAULTS = Object.freeze({ windowRows: 64, windowBytes: 1048576,
8
+ maxPending: 64, maxStatements: 1024, maxCursors: 64, allMaxRows: 100000,
9
+ allMaxBytes: 16777216, closeTimeoutMs: 5000, startupTimeoutMs: 10000 });
10
+ export const PROCESS_DEFAULTS = Object.freeze({ ...WORKER_DEFAULTS, closeTimeoutMs: 1000,
11
+ maxOwners: 4, timeoutMs: 250, maxRequestBytes: 1048576 });
7
12
  const operations = new Set(['exec', 'prepare', 'run', 'get', 'iterate', 'next', 'return', 'finalize', 'close']);
8
13
 
9
14
  /** @param {number} generation @param {boolean} [transaction] @param {unknown} [cause] */
@@ -73,6 +78,16 @@ export function positiveOption(name, value, fallback) {
73
78
  return value;
74
79
  }
75
80
 
81
+ /** One validation and spelling of shared transport credits.
82
+ * @param {any} configuration @param {typeof WORKER_DEFAULTS} [defaults] */
83
+ export function workerSettings(configuration, defaults = WORKER_DEFAULTS) {
84
+ const values = Object.fromEntries(Object.keys(WORKER_DEFAULTS).map((name) =>
85
+ [name, positiveOption(name, configuration[name], defaults[name])]));
86
+ return { limits: { rows: values.windowRows, bytes: values.windowBytes, statements: values.maxStatements, cursors: values.maxCursors },
87
+ maxPending: values.maxPending, allRows: values.allMaxRows, allBytes: values.allMaxBytes,
88
+ closeMs: values.closeTimeoutMs, startupMs: values.startupTimeoutMs };
89
+ }
90
+
76
91
  /** @param {string} reason @param {number} depth */
77
92
  export function queueFailure(reason, depth) {
78
93
  return Object.assign(new DbRuntimeError('JD2091', reason), { class: 'queue', retryable: true, depth });
package/src/errors.js CHANGED
@@ -112,6 +112,7 @@ export const DB_CODES = Object.freeze({
112
112
  JD2094: 'the durable snapshot failed and the connection is invalid',
113
113
  JD2095: 'the trusted SQL or synchronous transaction authority was refused',
114
114
  JD2096: 'a persistence invariant rejected the mutation',
115
+ JD2097: 'the supervised operation was cancelled or exceeded its response deadline',
115
116
  });
116
117
 
117
118
  /**
package/src/index.js CHANGED
@@ -105,4 +105,4 @@ export { planInvariants } from './ddl.js';
105
105
  export { planPhysicalMigration } from './migrate.js';
106
106
  export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
107
107
  export { defineTable, planTable } from './dialects/sqlite-schema.js';
108
- export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
108
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './table-migration.js';
package/src/mutation.js CHANGED
@@ -1,7 +1,7 @@
1
1
  //@ts-check
2
2
  /** Bounded column mutation documents, lowered through the entity's writer plan. */
3
3
  import { analyzeQuery } from '@jarenjs/json/query';
4
- import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { createBoundedCache } from '@jarenjs/core/cache';
5
5
  import { chain, attempt } from './driver.js';
6
6
  import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
7
7
  import { entityShape, planEntityPredicate } from './plan.js';
@@ -10,12 +10,13 @@ import { physicalSelection } from './physical.js';
10
10
  import { utf8Length } from './cursor.js';
11
11
  import { relationalEmitter } from './dialects/sqlite-relational.js';
12
12
 
13
- /** Compile once per document, execute within the existing guarded transaction.
13
+ /** Bind each document, reuse its SQL statement within the guarded transaction.
14
14
  * @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
15
15
  export function createEntityMutation(connection, entity, mapping, core) {
16
16
  const dialect = connection.dialect;
17
17
  const q = dialect.quoteIdentifier;
18
- const plans = new Map();
18
+ // Cache executable structure, never a value-bearing document or its bindings.
19
+ const statements = createBoundedCache(64);
19
20
  const fail = (reason) => { throw new DbCompileError('JD0038', reason, entity.docPath); };
20
21
  const column = (name) => {
21
22
  const c = mapping.columns.find((entry) => entry.name === name);
@@ -56,15 +57,17 @@ export function createEntityMutation(connection, entity, mapping, core) {
56
57
  const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
57
58
  const table = q(mapping.table);
58
59
  const sqlExpression = (expression, inline = false) => {
59
- const mapped = (value) => {
60
- if (Array.isArray(value)) return value.map(mapped);
60
+ const mapped = (value, depth = 0) => {
61
+ if (depth > 64) fail('SQL expression nesting exceeds 64');
62
+ const next = (child) => mapped(child, depth + 1);
63
+ if (Array.isArray(value)) return value.map(next);
61
64
  if (value === null || typeof value !== 'object') return value;
62
65
  if (value.$sql === 'value') return value;
63
66
  if (value.$sql === 'column') {
64
67
  if (value.table !== undefined && value.table !== 'it') fail('mutation column expressions refer to the current entity');
65
68
  return { $sql: 'column', name: column(value.name).physical };
66
69
  }
67
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapped(item)]));
70
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, next(item)]));
68
71
  };
69
72
  const emitter = relationalEmitter({ inline });
70
73
  const result = emitter.expr(mapped(expression));
@@ -187,19 +190,13 @@ export function createEntityMutation(connection, entity, mapping, core) {
187
190
  }
188
191
  }
189
192
  sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
190
- return { sql, params, returning, maxRows, maxBytes, statement: null };
193
+ return { sql, params, returning, maxRows, maxBytes };
191
194
  };
192
195
  return (document) => {
193
- const key = canonicalizeJson(document);
194
- let plan = plans.get(key);
195
- if (!plan) {
196
- plan = compile(document);
197
- if (plans.size >= 64) plans.delete(plans.keys().next().value);
198
- plans.set(key, plan);
199
- }
196
+ const plan = compile(document);
200
197
  return connection.transaction(() => {
201
- plan.statement ??= connection.prepare(plan.sql);
202
- return chain(plan.statement, (statement) => chain(attempt(() => statement.all(plan.params),
198
+ const prepared = statements.getOrCreate(plan.sql, (text) => connection.prepare(text));
199
+ return chain(prepared, (statement) => chain(attempt(() => statement.all(plan.params),
203
200
  (error) => String(error?.message).includes('jaren-mutation-row-bound')
204
201
  ? new DbRuntimeError('JD2007', 'insert-select exceeded its source row bound', { cause: error })
205
202
  : wrapDriverError(error, { collection: entity.name, docPath: entity.docPath })), (rows) => {
@@ -2,5 +2,5 @@
2
2
  /** Lightweight column-first SQLite authoring and execution. */
3
3
  export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
4
4
  export { defineTable, planTable } from './dialects/sqlite-schema.js';
5
- export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
5
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './table-migration.js';
6
6
  export { sqliteDialect } from './dialects/sqlite.js';
@@ -3,7 +3,7 @@
3
3
  import { hashContent } from '@jarenjs/core/string';
4
4
  import { canonicalizeJson } from '@jarenjs/json/canonical';
5
5
  import { DbCompileError } from './errors.js';
6
- import { defineTable, planTable } from './dialects/sqlite-schema.js';
6
+ import { defineTable, planTable, schemaChangeSql } from './dialects/sqlite-schema.js';
7
7
  import { relationalEmitter, relationalIdentifier as q, sql } from './dialects/sqlite-relational.js';
8
8
  import { sqliteDialect as dialect, sqliteTableMigration } from './dialects/sqlite.js';
9
9
  import { sqlTokens } from './dialects/check-read.js';
@@ -17,6 +17,39 @@ const sync = (connection) => {
17
17
  if (connection.dialect.name !== 'sqlite' || !connection.synchronous || connection.mustQueue) refuse('table migration requires an available synchronous SQLite connection');
18
18
  };
19
19
 
20
+ /** Connection settings that govern the meaning of an additive/drop/rename plan. */
21
+ function schemaSettings(connection) {
22
+ return ['foreign_keys', 'legacy_alter_table', 'schema_version'].map((name) =>
23
+ connection.prepare(dialect.introspect.pragma(name)).get([])[name]);
24
+ }
25
+
26
+ /** Review one native main-schema change without changing the database.
27
+ * Plans describe one source snapshot; callers own durable migration receipts.
28
+ * @param {any} connection @param {any} operation */
29
+ export function planSchemaChange(connection, operation) {
30
+ sync(connection);
31
+ const text = schemaChangeSql(operation);
32
+ const body = { version: 1, operation: structuredClone(operation), sql: text,
33
+ source: schema(connection), settings: schemaSettings(connection) };
34
+ return { ...body, checksum: fingerprint({ ...body, operation: text }) };
35
+ }
36
+
37
+ /** Apply a reviewed native change atomically, refusing schema/settings drift.
38
+ * Replan after any schema change; only explicit drop ifExists handles absence.
39
+ * @param {any} connection @param {ReturnType<typeof planSchemaChange>} plan */
40
+ export function applySchemaChange(connection, plan) {
41
+ sync(connection);
42
+ const { checksum, ...body } = plan;
43
+ if (body.version !== 1 || checksum !== fingerprint({ ...body, operation: plan.sql }) || plan.sql !== schemaChangeSql(plan.operation)) refuse('schema change checksum or statement differs');
44
+ return connection.transaction(() => {
45
+ const before = schema(connection);
46
+ if (fingerprint(before) !== fingerprint(plan.source) || fingerprint(schemaSettings(connection)) !== fingerprint(plan.settings))
47
+ refuse('source schema or connection settings changed after planning');
48
+ connection.exec(plan.sql);
49
+ return { changed: fingerprint(before) === fingerprint(schema(connection)) ? 0 : 1 };
50
+ }, { mode: 'immediate' });
51
+ }
52
+
20
53
  /** Inspect a live schema and generate a table plan without changing it.
21
54
  * Rebuilds require explicit opt-in. Unlisted indexes and triggers are preserved.
22
55
  * @param {any} connection @param {any} definition
package/types/index.d.ts CHANGED
@@ -514,6 +514,8 @@ export interface StoreCapabilities {
514
514
  readonly sessions: boolean;
515
515
  readonly sessionReason: string | null;
516
516
  readonly worker: boolean;
517
+ readonly process: boolean;
518
+ readonly ownerTermination: boolean;
517
519
  readonly pooling: boolean;
518
520
  readonly poolReaders: number;
519
521
  readonly poolWriters: number;
@@ -2043,4 +2045,4 @@ export declare function planPhysicalMigration(connection: unknown, fromModel: un
2043
2045
  options: { id: string; steps: readonly unknown[]; dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2044
2046
  assertions?: readonly { sql: string; params?: readonly unknown[]; expected: readonly unknown[] }[] }): unknown;
2045
2047
 
2046
- export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended } from './relational.js';
2048
+ export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './relational.js';
@@ -0,0 +1,33 @@
1
+ import type { NodeOpenOptions } from './node.js';
2
+ import type { CancellationCapabilities, Driver } from './index.js';
3
+ import type { NodeWorkerConnection, NodeWorkerOptions, WorkerMetrics } from './node-worker.js';
4
+
5
+ export interface NodeProcessOptions extends NodeWorkerOptions { maxOwners?: number; timeoutMs?: number; maxRequestBytes?: number }
6
+ export interface ProcessSettlement {
7
+ readonly path: string | null;
8
+ readonly generation: number;
9
+ readonly pid: number | null;
10
+ readonly status: 'starting' | 'healthy' | 'quarantined' | 'exited';
11
+ readonly transaction: 'none' | 'active' | 'committed' | 'rolled-back' | 'unknown';
12
+ readonly safeToReplace: boolean;
13
+ readonly exitCode: number | null;
14
+ readonly exitSignal: string | null;
15
+ }
16
+ export interface NodeProcessConnection extends NodeWorkerConnection {
17
+ readonly capabilities: Readonly<Record<string, unknown>> & {
18
+ readonly process: true; readonly ownerTermination: true;
19
+ readonly cancellation: CancellationCapabilities & {readonly midStatement: false};
20
+ };
21
+ supervise<T>(body: (connection: NodeProcessConnection) => T | Promise<T>, options?: {signal?: AbortSignal; timeoutMs?: number}): Promise<T>;
22
+ cancel(reason?: string): Error;
23
+ settlement(): ProcessSettlement;
24
+ /** Resolves only after the OS reports owner exit, never from a caller deadline. */
25
+ settled(): Promise<ProcessSettlement>;
26
+ metrics(): WorkerMetrics & {readonly owner: ProcessSettlement; readonly supervised: number};
27
+ restart(): Promise<NodeProcessConnection>;
28
+ }
29
+ export interface NodeProcessDriver extends Driver {
30
+ open(path?: string, options?: NodeOpenOptions): Promise<NodeProcessConnection>;
31
+ metrics(): Readonly<{capacity: number; owners: number; quarantined: number; healthy: number}>;
32
+ }
33
+ export declare function nodeProcessDriver(options?: NodeProcessOptions): NodeProcessDriver;
@@ -4,7 +4,7 @@ export type SqlInput = SqlValue | SqlExpression;
4
4
  export type SqlOperator = '=' | '<>' | '<' | '<=' | '>' | '>=' | 'IS' | 'IS NOT'
5
5
  | '+' | '-' | '*' | '/' | '%' | '||' | 'AND' | 'OR' | 'LIKE' | 'NOT LIKE' | 'GLOB';
6
6
  export type SqlFunction = 'coalesce' | 'nullif' | 'trim' | 'ltrim' | 'rtrim' | 'lower' | 'upper'
7
- | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid'
7
+ | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid' | 'json_type'
8
8
  | 'count' | 'sum' | 'total' | 'avg' | 'min' | 'max'
9
9
  | 'date' | 'time' | 'datetime' | 'julianday' | 'unixepoch' | 'strftime';
10
10
  export type SqlType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB' | 'NUMERIC';
@@ -78,8 +78,13 @@ export interface TableColumn {
78
78
  readonly default?: SqlInput; readonly collation?: SqlCollation;
79
79
  readonly identity?: 'rowid' | 'autoincrement'; readonly check?: SqlInput;
80
80
  readonly generated?: SqlInput; readonly stored?: boolean;
81
+ readonly references?: ColumnReference;
81
82
  }
82
83
  export type ForeignKeyAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
84
+ export interface ColumnReference {
85
+ readonly table: string; readonly columns: readonly [string];
86
+ readonly onDelete?: ForeignKeyAction; readonly onUpdate?: ForeignKeyAction; readonly deferred?: boolean;
87
+ }
83
88
  export type TableConstraint = { readonly name?: string } & (
84
89
  { readonly kind: 'unique'; readonly columns: readonly string[] }
85
90
  | { readonly kind: 'check'; readonly expression: SqlInput }
@@ -111,4 +116,17 @@ export interface TableMigrationPlan {
111
116
  export declare function planTableMigration(connection: unknown, definition: TableDefinition, options: TableMigrationOptions): TableMigrationPlan;
112
117
  export declare function applyTableMigration(connection: unknown, plan: TableMigrationPlan): { changed: number };
113
118
  export declare function withForeignKeysSuspended<T>(connection: unknown, fn: () => T): T;
119
+ export type SchemaChange =
120
+ | { readonly op: 'addColumn'; readonly table: string; readonly column: TableColumn }
121
+ | { readonly op: 'dropIndex'; readonly name: string; readonly ifExists?: boolean }
122
+ | { readonly op: 'renameTable'; readonly table: string; readonly to: string }
123
+ | { readonly op: 'dropTable'; readonly table: string; readonly ifExists?: boolean };
124
+ export interface SchemaChangePlan {
125
+ readonly version: 1; readonly operation: SchemaChange; readonly sql: string;
126
+ readonly source: readonly unknown[]; readonly settings: readonly number[]; readonly checksum: string;
127
+ }
128
+ /** Main-schema snapshot; does not execute SQL or infer replay/disposition policy. */
129
+ export declare function planSchemaChange(connection: unknown, operation: SchemaChange): SchemaChangePlan;
130
+ /** Refuses stale source/settings under an immediate transaction. */
131
+ export declare function applySchemaChange(connection: unknown, plan: SchemaChangePlan): { changed: number };
114
132
  export { sqliteDialect } from './index.js';