@jarenjs/db 0.86.0 → 0.89.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,104 @@
1
+ //@ts-check
2
+ /** Acquisition and complete physical acceptance for migration entry points. */
3
+ import { isThenable } from '@jarenjs/core/function';
4
+ import { chain, toPromise } from './driver.js';
5
+ import { DbCompileError } from './errors.js';
6
+ import { readSchema } from './introspect.js';
7
+ import { ENGINE_TABLES } from './engine-metadata.js';
8
+ import { comparableDeclaredSql } from './schema-sql.js';
9
+
10
+ /** Keep synchronous borrowed work synchronous; close only acquired resources.
11
+ * Cleanup failures retain the original failure as well as the close failure.
12
+ * @param {any} target @param {(connection:any)=>any} run @returns {any} */
13
+ export function withMigrationConnection(target, run) {
14
+ const borrowed = target?.connection !== undefined;
15
+ if (!target || typeof target !== 'object' || (borrowed
16
+ ? target.driver !== undefined || target.path !== undefined || target.busyTimeout !== undefined
17
+ || typeof target.connection?.prepare !== 'function' || typeof target.connection?.transaction !== 'function'
18
+ : typeof target.driver?.open !== 'function'))
19
+ throw new TypeError('migration target needs { driver, path? } or { connection }');
20
+ if (borrowed) return run(target.connection);
21
+ return toPromise(chain(target.driver.open(target.path ?? ':memory:', { timeout: target.busyTimeout ?? 5000 }), (connection) => {
22
+ const finish = (value) => chain(connection.close(), () => value);
23
+ const fail = (error) => {
24
+ const both = (closeError) => { throw new AggregateError([error, closeError], 'migration failed and its connection could not close', { cause: error }); };
25
+ let closed;
26
+ try { closed = connection.close(); }
27
+ catch (closeError) { return both(closeError); }
28
+ if (isThenable(closed)) return closed.then(() => { throw error; }, both);
29
+ throw error;
30
+ };
31
+ let result;
32
+ try { result = run(connection); }
33
+ catch (error) { return fail(error); }
34
+ return isThenable(result) ? result.then(finish, fail) : finish(result);
35
+ }));
36
+ }
37
+
38
+ /** SQLite supplies the main database's canonical filename; private in-memory
39
+ * databases have no filename and cannot be identified by an empty string.
40
+ * @param {any} connection @returns {any} value-or-promise of string or null */
41
+ export function sqliteDatabasePath(connection) {
42
+ return chain(connection.prepare(connection.dialect.introspect.pragma('database_list')), (statement) =>
43
+ chain(statement.all([]), (rows) => rows.find((row) => row.name === 'main')?.file || null));
44
+ }
45
+
46
+ /** Reject an alias before any shadow callback or replay statement can write.
47
+ * Native bindings identify files by device/inode; injected SQLite drivers may
48
+ * supply the same hook, with the engine's canonical filename as the fallback.
49
+ * @param {any} primary @param {any} shadow @param {any} driver @returns {any} */
50
+ export function verifyShadowOwnership(primary, shadow, driver) {
51
+ if (primary.dialect.name !== 'sqlite' || shadow.dialect.name !== 'sqlite') return null;
52
+ const identify = typeof driver.databaseIdentity === 'function'
53
+ ? (connection) => driver.databaseIdentity(connection) : sqliteDatabasePath;
54
+ return chain(identify(primary), (source) => chain(identify(shadow), (target) => {
55
+ if (source !== null && source !== undefined && source === target)
56
+ throw new DbCompileError('JD0021', 'shadow replay needs a different database from the primary');
57
+ }));
58
+ }
59
+
60
+ /** Validate a complete owned-program inventory. Omitted tables are derived from
61
+ * object owners; explicit absent table names express reviewed drops.
62
+ * @param {any} target @returns {{objects:any[],tables:string[]}} */
63
+ export function physicalTargetOf(target) {
64
+ const fail = () => { throw new DbCompileError('JD0021', 'physicalTarget requires complete objects and optional owned tables'); };
65
+ if (!target || typeof target !== 'object' || !Array.isArray(target.objects)
66
+ || Object.keys(target).some((k) => !['objects', 'tables'].includes(k))) fail();
67
+ const names = new Set();
68
+ const objects = target.objects.map((object) => {
69
+ if (!object || !['table', 'view', 'index', 'trigger'].includes(object.type)
70
+ || typeof object.name !== 'string' || !object.name || typeof object.owner !== 'string' || !object.owner
71
+ || typeof object.sql !== 'string' || ENGINE_TABLES.has(object.name) || ENGINE_TABLES.has(object.owner)
72
+ || names.has(`${object.type}:${object.name}`)) fail();
73
+ names.add(`${object.type}:${object.name}`);
74
+ return { type: object.type, name: object.name, owner: object.owner,
75
+ sql: comparableDeclaredSql(object.sql) };
76
+ });
77
+ const tables = target.tables ?? [...new Set(objects.map((o) => o.owner))];
78
+ if (!Array.isArray(tables) || new Set(tables).size !== tables.length
79
+ || tables.some((t) => typeof t !== 'string' || !t || ENGINE_TABLES.has(t))
80
+ || objects.some((o) => !tables.includes(o.owner))) fail();
81
+ if (objects.some((o) => ['index', 'trigger'].includes(o.type)
82
+ && !objects.some((t) => ['table', 'view'].includes(t.type) && t.name === o.owner))) fail();
83
+ return { objects, tables };
84
+ }
85
+
86
+ /** One acceptance owner for apply, repeated startup and status. Exact quoted
87
+ * programs and physical column order remain significant; unrelated tables stay
88
+ * outside the reviewed ownership set.
89
+ * @param {any} connection @param {any} target @returns {any} */
90
+ export function comparePhysicalTarget(connection, target) {
91
+ if (connection.dialect.name !== 'sqlite') throw new DbCompileError('JD0021', 'complete physical target verification is qualified for SQLite');
92
+ const wanted = physicalTargetOf(target);
93
+ const selection = [...new Set([...wanted.tables, ...wanted.objects.map((o) => o.name)])];
94
+ return chain(readSchema(connection, { tables: selection }), (schema) => {
95
+ const actual = new Map(schema.objects.map((o) => [`${o.type}:${o.name}`, o]));
96
+ for (const object of wanted.objects) {
97
+ const key = `${object.type}:${object.name}`, have = actual.get(key);
98
+ if (!have) return `missing ${key}`;
99
+ if (object.owner !== have.owner || object.sql !== comparableDeclaredSql(have.sql)) return `different ${key}`;
100
+ actual.delete(key);
101
+ }
102
+ return actual.size ? `unexpected ${actual.keys().next().value}` : null;
103
+ });
104
+ }
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) => {
@@ -0,0 +1,207 @@
1
+ //@ts-check
2
+ /** Bounded physical migration reads and key-preserving column assignments. */
3
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
4
+ import { createBoundedCache } from '@jarenjs/core/cache';
5
+ import { setObjectMember } from '@jarenjs/core/object';
6
+ import { chain, isThenable } from './driver.js';
7
+ import { DbCompileError } from './errors.js';
8
+ import { readSchema } from './introspect.js';
9
+ import { columnCodec, physicalSelection, verifyPhysical } from './physical.js';
10
+ import { entityCore } from './entity.js';
11
+ import { sqliteTableMigration } from './dialects/sqlite.js';
12
+
13
+ const member = (value, name) => Object.hasOwn(value, name) ? value[name] : undefined;
14
+ const same = (a, b) => a === undefined || b === undefined
15
+ ? a === b : canonicalizeJson(a) === canonicalizeJson(b);
16
+
17
+ /** Prove text cursors survived the binding without replacement characters. */
18
+ function textKeyReadPlan(connection, mapping, keys) {
19
+ const columns = keys.filter((column) => column.codec === 'text');
20
+ if (columns.length === 0) return null;
21
+ const names = new Set(mapping.columns.map((column) => column.physical.toLowerCase()));
22
+ const aliases = columns.map((_, index) => {
23
+ let name = `__jaren_key_bytes_${index}`;
24
+ while (names.has(name.toLowerCase())) name += '_';
25
+ names.add(name.toLowerCase());
26
+ return name;
27
+ });
28
+ const q = connection.dialect.quoteIdentifier;
29
+ const projection = columns.map((column, index) =>
30
+ `${sqliteTableMigration.binaryCast(q(column.physical))} AS ${q(aliases[index])}`).join(', ');
31
+ return chain(connection.prepare(connection.dialect.introspect.pragma('encoding')), (statement) => chain(statement.get([]), (row) => {
32
+ // Raw SQLite text bytes use the database encoding, including UTF-16 files.
33
+ // A fatal decoder refuses malformed text; preserving BOM makes equality
34
+ // with the binding's public key exact, without changing its byte identity.
35
+ const decoder = new TextDecoder(row.encoding, { fatal: true, ignoreBOM: true });
36
+ // Some native bindings read a leading BOM correctly but strip it when
37
+ // binding the cursor back. Probe once; those keys must refuse before paging.
38
+ const probe = `SELECT ${sqliteTableMigration.binaryCast(connection.dialect.parameterRef(1, 'text'))} AS ${q('bytes')}`;
39
+ return chain(connection.prepare(probe), (statement) => chain(statement.get(['\uFEFFx']), (bound) => {
40
+ const keepsLeadingBom = decoder.decode(bound.bytes) === '\uFEFFx';
41
+ const read = (rows) => {
42
+ for (const row of rows) for (let index = 0; index < columns.length; index++) {
43
+ let decoded;
44
+ try { decoded = decoder.decode(row[aliases[index]]); }
45
+ catch { /* The common refusal below also covers replacement decoding. */ }
46
+ if (decoded === undefined || decoded !== row[columns[index].physical]
47
+ || !keepsLeadingBom && decoded.startsWith('\uFEFF'))
48
+ throw new DbCompileError('JD0021', `physical '${mapping.table}' key '${columns[index].name}' cannot round-trip through its SQLite text encoding`);
49
+ }
50
+ return rows.map((row) => {
51
+ const clean = { ...row };
52
+ for (const alias of aliases) delete clean[alias];
53
+ return clean;
54
+ });
55
+ };
56
+ return { projection, read };
57
+ }));
58
+ }));
59
+ }
60
+
61
+ /** Validate the physical read before preparing a statement over mapped columns. */
62
+ function physicalReader(connection, mapping, batchSize, check) {
63
+ if (connection.dialect.name !== 'sqlite' || mapping.document !== false
64
+ || !mapping.keys?.length || !['table', 'view'].includes(mapping.kind))
65
+ throw new DbCompileError('JD0021', 'a physical migration read needs a declared SQLite table or view and mapped keys');
66
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1)
67
+ throw new TypeError('physical migration batchSize must be a positive safe integer');
68
+ check?.();
69
+ return chain(readSchema(connection), (schema) => chain(verifyPhysical(connection, mapping, schema), () => {
70
+ const dialect = connection.dialect, q = dialect.quoteIdentifier;
71
+ const keys = mapping.keys.map((name) => mapping.columns.find((column) => column.name === name));
72
+ if (keys.some((column) => !column || !['text', 'integer', 'bigint'].includes(column.codec)))
73
+ throw new DbCompileError('JD0021', 'physical migration keys need text, integer or bigint codecs');
74
+ return chain(textKeyReadPlan(connection, mapping, keys), (textKeys) => {
75
+ // One explicit ordering governs both the cursor and comparisons. A table's
76
+ // actual PRIMARY KEY proves uniqueness; a view has no such proof and uses
77
+ // bounded offset pages. Big integers remain strings only at the binding
78
+ // boundary; comparison reads the INTEGER column with its SQLite affinity.
79
+ const ordered = keys.map((column) => column.codec === 'text'
80
+ ? dialect.codepoint(q(column.physical)) : q(column.physical));
81
+ const keyValues = (row) => keys.map((column) => {
82
+ const codec = columnCodec(column);
83
+ return codec.encode(codec.decode(row[column.physical]));
84
+ });
85
+ const table = q(mapping.table), projection = physicalSelection(mapping, dialect);
86
+ const select = `SELECT ${projection}${textKeys === null ? '' : `, ${textKeys.projection}`} FROM ${table}`;
87
+ const ordering = ` ORDER BY ${ordered.join(', ')}`;
88
+ const bounded = dialect.limitClause(batchSize, undefined);
89
+ const keyset = mapping.kind === 'table';
90
+ const seek = ` WHERE (${ordered.join(', ')}) > (${keys.map((_, i) => dialect.parameterRef(i + 1, 'key')).join(', ')})`;
91
+ const count = () => chain(connection.prepare(`SELECT COUNT(*) AS ${q('n')} FROM ${table}`), (s) => chain(s.get([]), (row) => Number(row.n)));
92
+ const walk = (handle) => chain(connection.prepare(select + ordering + bounded), (first) =>
93
+ chain(keyset ? connection.prepare(select + seek + ordering + bounded) : null, (following) => {
94
+ let after = null, offset = 0;
95
+ const done = Symbol('physical-migration-done');
96
+ const consume = (rows) => {
97
+ if (rows.length === 0) return done;
98
+ if (textKeys !== null) rows = textKeys.read(rows);
99
+ // Capture the continuation before a handler can mutate its batch.
100
+ if (keyset) after = keyValues(rows[rows.length - 1]);
101
+ offset += rows.length;
102
+ return handle(rows);
103
+ };
104
+ const advance = () => {
105
+ for (;;) {
106
+ check?.();
107
+ const page = keyset
108
+ ? (after === null ? first.all([]) : following.all(after))
109
+ : offset === 0 ? first.all([]) : chain(connection.prepare(select + ordering + dialect.limitClause(batchSize, offset)), (s) => s.all([]));
110
+ if (isThenable(page)) return page.then((rows) => {
111
+ const result = consume(rows);
112
+ return result === done ? null : chain(result, advance);
113
+ });
114
+ const result = consume(page);
115
+ if (result === done) return null;
116
+ if (isThenable(result)) return result.then(advance);
117
+ }
118
+ };
119
+ return advance();
120
+ }));
121
+ return { walk, count, keys, ordered, table };
122
+ });
123
+ }));
124
+ }
125
+
126
+ /** Read mapped rows without synthetic aliases that could overwrite stored names.
127
+ * @param {any} connection @param {any} mapping @param {number} batchSize
128
+ * @param {(rows: any[]) => any} handle @param {(() => void)} [check] @returns {any} */
129
+ export function walkPhysicalRows(connection, mapping, batchSize, handle, check) {
130
+ return chain(physicalReader(connection, mapping, batchSize, check), (reader) => reader.walk(handle));
131
+ }
132
+
133
+ /** Transform column-only entities without application defaults/version stamps.
134
+ * The migration's surrounding savepoint owns every assignment and invariant.
135
+ * @param {any} connection @param {{entity: any, mapping: any}} target
136
+ * @param {any} operation @param {{batchSize: number, runtime?: any, check?: Function,
137
+ * onProgress?: Function, migration: string, collection: string}} options @returns {any} */
138
+ export function transformPhysicalRows(connection, target, operation, options) {
139
+ const { entity, mapping } = target;
140
+ const core = entityCore(connection, entity, mapping, null, options.runtime);
141
+ core.plan.writable();
142
+ const columns = core.plan.scalarColumns;
143
+ const known = new Set(columns.map((column) => column.name));
144
+ const keyNames = new Set(core.plan.keys);
145
+ const statements = createBoundedCache(64);
146
+ return chain(physicalReader(connection, mapping, options.batchSize, options.check), (reader) =>
147
+ chain(reader.count(), (initialCount) => {
148
+ let visited = 0, transformed = 0;
149
+ const apply = (row) => {
150
+ const before = core.plan.merge(row);
151
+ const identity = Object.fromEntries(core.plan.keys.map((name) => [name, before[name]]));
152
+ const output = operation.apply(structuredClone(before), canonicalizeJson(identity));
153
+ for (const name of Object.keys(output)) if (!known.has(name))
154
+ operation.fail(`a physical transform cannot store unknown or relation member '${name}'`);
155
+ const values = [];
156
+ const candidate = {};
157
+ for (const column of columns) {
158
+ const name = column.name;
159
+ const supplied = Object.hasOwn(output, name);
160
+ const prior = member(before, name), raw = supplied ? output[name] : undefined;
161
+ const value = (column.generated || column.databaseDefault) && !supplied
162
+ ? prior : column.codecPlan.normalize(raw);
163
+ if (column.generated && !same(value, prior)) operation.fail(`generated column '${name}' is database-owned`);
164
+ if (keyNames.has(name) && !same(value, prior)) operation.fail(`a physical transform cannot change key '${name}'`);
165
+ if (value !== undefined) setObjectMember(candidate, name, value);
166
+ if (!column.generated && !keyNames.has(name) && !same(value, prior))
167
+ values.push({ name, value: core.plan.encodeColumn(name, raw) });
168
+ }
169
+ if (values.length === 0) return null;
170
+ const dialect = connection.dialect, q = dialect.quoteIdentifier;
171
+ const assignments = values.map((value, i) => `${q(core.plan.physicalName(value.name))} = ${dialect.parameterRef(i + 1, 'value')}`);
172
+ const where = reader.ordered.map((column, i) => `${column} = ${dialect.parameterRef(values.length + i + 1, 'key')}`).join(' AND ');
173
+ const sql = `UPDATE ${reader.table} SET ${assignments.join(', ')} WHERE ${where}`;
174
+ const params = [...values.map((value) => value.value), ...core.plan.keys.map((name) => core.plan.encodeColumn(name, before[name]))];
175
+ return chain(statements.getOrCreate(sql, (text) => connection.prepare(text)), (statement) => chain(statement.run(params), (result) => {
176
+ if (Number(result.changes) !== 1) operation.fail('the physical transform did not update exactly its addressed row');
177
+ return chain(core.get(core.plan.keys.length === 1 ? before[core.plan.keys[0]] : identity), (stored) => {
178
+ if (!stored || core.plan.keys.some((name) => !same(stored[name], before[name])))
179
+ operation.fail('the physical transform changed or lost its addressed key');
180
+ if (values.some(({ name }) => !same(member(stored, name), member(candidate, name))))
181
+ operation.fail('the physical transform did not store its requested column values');
182
+ core.plan.checkMutation('update', before, stored);
183
+ transformed++;
184
+ return null;
185
+ });
186
+ }));
187
+ };
188
+ return chain(reader.walk((rows) => {
189
+ visited += rows.length;
190
+ if (visited > initialCount) operation.fail('a physical transform changed source row membership');
191
+ let index = 0;
192
+ const next = () => {
193
+ while (index < rows.length) {
194
+ const result = apply(rows[index++]);
195
+ if (isThenable(result)) return result.then(next);
196
+ }
197
+ options.onProgress?.({ migration: options.migration, collection: options.collection, transformed });
198
+ return null;
199
+ };
200
+ return next();
201
+ }), () => chain(reader.count(), (finalCount) => {
202
+ if (finalCount !== initialCount || visited !== initialCount)
203
+ operation.fail('a physical transform changed source row membership');
204
+ return transformed;
205
+ }));
206
+ }));
207
+ }
@@ -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';
@@ -0,0 +1,184 @@
1
+ //@ts-check
2
+ /** Conservative SQLite declaration identity. Quoted bytes are never formatting. */
3
+
4
+ /** @typedef {{ kind: string, text: string }} SqlToken */
5
+ /** @typedef {{ columnOrder?: 'preserve' | 'ignore' }} DeclaredSqlOptions */
6
+
7
+ /** @param {string} reason @returns {never} */
8
+ function invalid(reason) {
9
+ throw new TypeError(`SQL declaration cannot be compared: ${reason}`);
10
+ }
11
+
12
+ /** SQLite whitespace excludes Unicode identifier characters and vertical tab. */
13
+ const whitespace = (/** @type {number} */ code) => code === 32 || code === 9
14
+ || code === 10 || code === 12 || code === 13;
15
+ const wordStart = (/** @type {number} */ code) => code >= 128 || code === 95
16
+ || code >= 65 && code <= 90 || code >= 97 && code <= 122;
17
+ const wordPart = (/** @type {number} */ code) => wordStart(code) || code === 36
18
+ || code >= 48 && code <= 57;
19
+
20
+ /** Lex before discarding comments or whitespace; retain each quoted token raw.
21
+ * @param {string} sql @returns {SqlToken[]} */
22
+ function tokensOf(sql) {
23
+ if (typeof sql !== 'string') invalid('text is required');
24
+ /** @type {SqlToken[]} */
25
+ const tokens = [];
26
+ let depth = 0;
27
+ for (let i = 0; i < sql.length;) {
28
+ const start = i, c = sql[i];
29
+ if (whitespace(sql.charCodeAt(i))) { i++; continue; }
30
+ if (sql.startsWith('--', i)) {
31
+ const end = sql.indexOf('\n', i + 2);
32
+ i = end < 0 ? sql.length : end + 1;
33
+ continue;
34
+ }
35
+ if (sql.startsWith('/*', i)) {
36
+ const end = sql.indexOf('*/', i + 2);
37
+ if (end < 0) invalid('unterminated comment');
38
+ i = end + 2;
39
+ continue;
40
+ }
41
+ const blob = (c === 'x' || c === 'X') && sql[i + 1] === "'";
42
+ if (blob) i++;
43
+ const quote = sql[i];
44
+ if (blob || quote === "'" || quote === '"' || quote === '`' || quote === '[') {
45
+ const close = quote === '[' ? ']' : quote;
46
+ let closed = false;
47
+ for (i++; i < sql.length; i++) {
48
+ if (sql.charCodeAt(i) === 0) invalid('NUL in quoted text');
49
+ if (sql[i] !== close) continue;
50
+ if (quote !== '[' && sql[i + 1] === close) { i++; continue; }
51
+ i++; closed = true; break;
52
+ }
53
+ if (!closed) invalid('unterminated quoted token');
54
+ const text = sql.slice(start, i);
55
+ if (blob && !/^[xX]'(?:[0-9a-fA-F]{2})*'$/.test(text)) invalid('invalid blob literal');
56
+ tokens.push({ kind: blob ? 'blob' : quote === "'" ? 'string' : 'identifier', text });
57
+ continue;
58
+ }
59
+ if (wordStart(sql.charCodeAt(i))) {
60
+ while (++i < sql.length && wordPart(sql.charCodeAt(i))) { /* whole identifier */ }
61
+ tokens.push({ kind: 'word', text: sql.slice(start, i) });
62
+ continue;
63
+ }
64
+ const number = /^(?:0[xX][0-9a-fA-F](?:_?[0-9a-fA-F])*|(?:[0-9](?:_?[0-9])*(?:\.(?:[0-9](?:_?[0-9])*)?)?|\.[0-9](?:_?[0-9])*)(?:[eE][+-]?[0-9](?:_?[0-9])*)?)/.exec(sql.slice(i));
65
+ if (number !== null) {
66
+ tokens.push({ kind: 'number', text: number[0] });
67
+ i += number[0].length;
68
+ if (wordPart(sql.charCodeAt(i)) || sql[i] === '.') invalid('invalid numeric token');
69
+ continue;
70
+ }
71
+ const operator = /^(?:->>|->|\|\||<<|>>|<=|>=|==|!=|<>|[(),.;+*/%~&|=<>-])/.exec(sql.slice(i));
72
+ if (operator === null) invalid(`unsupported token at ${i}`);
73
+ const text = operator[0];
74
+ if (text === '(') depth++;
75
+ else if (text === ')' && --depth < 0) invalid('unbalanced parentheses');
76
+ tokens.push({ kind: 'symbol', text });
77
+ i += text.length;
78
+ }
79
+ if (depth !== 0) invalid('unbalanced parentheses');
80
+ if (tokens.length === 0) invalid('empty declaration');
81
+ return tokens;
82
+ }
83
+
84
+ /** @param {SqlToken | undefined} token @param {string} word */
85
+ const isWord = (token, word) => token?.kind === 'word' && token.text.toUpperCase() === word;
86
+
87
+ /** Remove only the CREATE prefix clause SQLite omits from its catalog.
88
+ * @param {SqlToken[]} tokens @returns {SqlToken[]} */
89
+ function withoutExistenceClause(tokens) {
90
+ if (!isWord(tokens[0], 'CREATE')) return tokens;
91
+ let at = 1;
92
+ if (isWord(tokens[at], 'TEMP') || isWord(tokens[at], 'TEMPORARY')) at++;
93
+ if (isWord(tokens[at], 'UNIQUE') || isWord(tokens[at], 'VIRTUAL')) at++;
94
+ if (!['TABLE', 'INDEX', 'TRIGGER', 'VIEW'].some((word) => isWord(tokens[at], word))) return tokens;
95
+ at++;
96
+ return isWord(tokens[at], 'IF') && isWord(tokens[at + 1], 'NOT') && isWord(tokens[at + 2], 'EXISTS')
97
+ ? [...tokens.slice(0, at), ...tokens.slice(at + 3)] : tokens;
98
+ }
99
+
100
+ /** Stable whitespace between tokens; punctuation cannot join words/operators.
101
+ * @param {SqlToken[]} tokens @returns {string} */
102
+ function render(tokens) {
103
+ let out = '';
104
+ for (let i = 0; i < tokens.length; i++) {
105
+ const token = tokens[i], previous = tokens[i - 1];
106
+ const tight = token.kind === 'symbol' && ['(', ')', ','].includes(token.text)
107
+ || previous?.kind === 'symbol' && ['(', ')', ','].includes(previous.text);
108
+ out += (i === 0 || tight ? '' : ' ') + token.text;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Reorder only ordinary named columns with no order-sensitive inline clauses.
114
+ * Table constraints and every token inside a definition retain their order.
115
+ * Unrecognized table shapes keep strict order rather than guessing equivalence.
116
+ * @param {SqlToken[]} tokens @returns {SqlToken[]} */
117
+ function namedColumnOrder(tokens) {
118
+ if (!isWord(tokens[0], 'CREATE')) return tokens;
119
+ let at = 1;
120
+ if (isWord(tokens[at], 'TEMP') || isWord(tokens[at], 'TEMPORARY')) at++;
121
+ if (!isWord(tokens[at++], 'TABLE')) return tokens;
122
+ const name = (/** @type {SqlToken | undefined} */ token) => token?.kind === 'word' || token?.kind === 'identifier';
123
+ if (!name(tokens[at++])) return tokens;
124
+ if (tokens[at]?.text === '.') {
125
+ at++;
126
+ if (!name(tokens[at++])) return tokens;
127
+ }
128
+ if (tokens[at]?.text !== '(') return tokens;
129
+ const open = at++;
130
+ /** @type {SqlToken[][]} */
131
+ const items = [];
132
+ let start = at, depth = 0, close = -1;
133
+ for (; at < tokens.length; at++) {
134
+ const token = tokens[at];
135
+ if (token.kind !== 'symbol') continue;
136
+ if (token.text === '(') depth++;
137
+ else if (token.text === ')') {
138
+ if (depth === 0) { items.push(tokens.slice(start, at)); close = at; break; }
139
+ depth--;
140
+ }
141
+ else if (token.text === ',' && depth === 0) { items.push(tokens.slice(start, at)); start = at + 1; }
142
+ }
143
+ if (close < 0 || items.some((item) => item.length === 0)) return tokens;
144
+ const constraint = (/** @type {SqlToken[]} */ item) => ['CONSTRAINT', 'PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN']
145
+ .some((word) => isWord(item[0], word));
146
+ const firstConstraint = items.findIndex(constraint);
147
+ const split = firstConstraint < 0 ? items.length : firstConstraint;
148
+ const columns = items.slice(0, split), constraints = items.slice(split);
149
+ if (constraints.some((item) => !constraint(item)) || columns.some((item) => !name(item[0]))) return tokens;
150
+ // Default evaluation and check/conflict/cascade order can change values,
151
+ // errors or surviving rows even when every access names its columns.
152
+ if (columns.some((item) => item.slice(1).some((token) =>
153
+ ['DEFAULT', 'CHECK', 'UNIQUE', 'REFERENCES', 'COLLATE', 'CONFLICT'].some((word) => isWord(token, word))))) return tokens;
154
+ columns.sort((a, b) => {
155
+ const left = render(a), right = render(b);
156
+ return left < right ? -1 : left > right ? 1 : 0;
157
+ });
158
+ const reordered = [...columns, ...constraints];
159
+ return [...tokens.slice(0, open + 1), ...reordered.flatMap((item, i) =>
160
+ i === 0 ? item : [{ kind: 'symbol', text: ',' }, ...item]), ...tokens.slice(close)];
161
+ }
162
+
163
+ /** A conservative declaration identity, preserving physical column order by default.
164
+ * `ignore` is for managed tables whose consumers address columns by name. Index,
165
+ * trigger and table-constraint order is always preserved; order-sensitive inline
166
+ * constraints and unfamiliar table structures retain strict column order too.
167
+ * Quoted bytes are exact. Comments and ordinary token whitespace are formatting.
168
+ * @param {string} sql
169
+ * @param {DeclaredSqlOptions} [options]
170
+ * @returns {string}
171
+ * @throws {TypeError} for malformed/unsupported lexical input or policy
172
+ */
173
+ export function comparableDeclaredSql(sql, options = {}) {
174
+ const columnOrder = options.columnOrder ?? 'preserve';
175
+ if (!['preserve', 'ignore'].includes(columnOrder)) invalid('unknown columnOrder policy');
176
+ const tokens = withoutExistenceClause(tokensOf(sql));
177
+ return render(columnOrder === 'ignore' ? namedColumnOrder(tokens) : tokens);
178
+ }
179
+
180
+ /** Normalize formatting only, through the declaration comparison owner.
181
+ * @param {string} sql @returns {string} */
182
+ export function normalizeDeclaredSql(sql) {
183
+ return comparableDeclaredSql(sql);
184
+ }