@procedures/sqlite 0.2.3 → 0.3.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +126 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/index.d.ts +3 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +6 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/module.d.ts +2 -1
  9. package/dist/module.d.ts.map +1 -1
  10. package/dist/module.js +93 -9
  11. package/dist/module.js.map +1 -1
  12. package/dist/procedures/db/backup.d.ts +19 -0
  13. package/dist/procedures/db/backup.d.ts.map +1 -0
  14. package/dist/procedures/db/backup.js +61 -0
  15. package/dist/procedures/db/backup.js.map +1 -0
  16. package/dist/procedures/db/index.d.ts +6 -0
  17. package/dist/procedures/db/index.d.ts.map +1 -0
  18. package/dist/procedures/db/index.js +6 -0
  19. package/dist/procedures/db/index.js.map +1 -0
  20. package/dist/procedures/db/migrate.d.ts +24 -0
  21. package/dist/procedures/db/migrate.d.ts.map +1 -0
  22. package/dist/procedures/db/migrate.js +110 -0
  23. package/dist/procedures/db/migrate.js.map +1 -0
  24. package/dist/procedures/db/schema.d.ts +13 -0
  25. package/dist/procedures/db/schema.d.ts.map +1 -0
  26. package/dist/procedures/db/schema.js +33 -0
  27. package/dist/procedures/db/schema.js.map +1 -0
  28. package/dist/procedures/db/shared.d.ts +25 -0
  29. package/dist/procedures/db/shared.d.ts.map +1 -0
  30. package/dist/procedures/db/shared.js +105 -0
  31. package/dist/procedures/db/shared.js.map +1 -0
  32. package/dist/procedures/db/tables.d.ts +12 -0
  33. package/dist/procedures/db/tables.d.ts.map +1 -0
  34. package/dist/procedures/db/tables.js +22 -0
  35. package/dist/procedures/db/tables.js.map +1 -0
  36. package/dist/procedures/db/transaction.d.ts +14 -0
  37. package/dist/procedures/db/transaction.d.ts.map +1 -0
  38. package/dist/procedures/db/transaction.js +44 -0
  39. package/dist/procedures/db/transaction.js.map +1 -0
  40. package/dist/storage.d.ts +123 -0
  41. package/dist/storage.d.ts.map +1 -0
  42. package/dist/storage.js +525 -0
  43. package/dist/storage.js.map +1 -0
  44. package/dist/types.d.ts +131 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +109 -0
  47. package/dist/types.js.map +1 -1
  48. package/package.json +9 -3
  49. package/src/index.ts +26 -0
  50. package/src/module.ts +120 -9
  51. package/src/procedures/db/backup.ts +62 -0
  52. package/src/procedures/db/index.ts +5 -0
  53. package/src/procedures/db/migrate.ts +121 -0
  54. package/src/procedures/db/schema.ts +71 -0
  55. package/src/procedures/db/shared.ts +109 -0
  56. package/src/procedures/db/tables.ts +27 -0
  57. package/src/procedures/db/transaction.ts +43 -0
  58. package/src/storage.ts +591 -0
  59. package/src/types.ts +268 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * db.migrate procedure (roadmap R44)
3
+ *
4
+ * The procedure applies the numbered migrations that the database does not have, in the order
5
+ * of their numbers. The migrations come from a folder (files named `<number>_<name>.sql`) or
6
+ * from the input. A table of the database (default `schema_migrations`) records each applied
7
+ * migration with the sha256 of its SQL.
8
+ *
9
+ * - The pending migrations run in one transaction: when one fails, none is applied.
10
+ * - A second call applies nothing, and does not write the file.
11
+ * - The SQL of an applied migration must not change: a different sha256 is an error, and the
12
+ * call applies nothing. (The sha256 reads line ends as LF, so a checkout with CRLF is the same.)
13
+ * - A migration must not hold BEGIN, COMMIT or ROLLBACK: the procedure runs the transaction.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { readdir, readFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { query, withConnection } from "@mark1russell7/docker-sqlite";
20
+ import { MIGRATION_TABLE_PATTERN, type DbMigrateInput, type DbMigrateOutput, type DbMigration } from "../../types.js";
21
+ import { databasePath, runStatements } from "./shared.js";
22
+
23
+ /** The name of a migration file: a number, then "_" or "-", then a name, then ".sql". */
24
+ const MIGRATION_FILE = /^(\d+)[_-](.+)\.sql$/i;
25
+
26
+ /** The default table of the applied migrations. */
27
+ export const DEFAULT_MIGRATION_TABLE = "schema_migrations";
28
+
29
+ interface Migration {
30
+ version: number;
31
+ name: string;
32
+ sql: string;
33
+ checksum: string;
34
+ }
35
+
36
+ function checksum(sql: string): string {
37
+ return createHash("sha256").update(sql.replace(/\r\n/g, "\n")).digest("hex");
38
+ }
39
+
40
+ /** The migrations of a folder: each file `<number>_<name>.sql`. Other files are not migrations. */
41
+ export async function migrationsOfFolder(folder: string): Promise<DbMigration[]> {
42
+ const entries = await readdir(folder, { withFileTypes: true });
43
+ const migrations: DbMigration[] = [];
44
+ for (const entry of entries) {
45
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".sql")) continue;
46
+ const match = MIGRATION_FILE.exec(entry.name);
47
+ if (!match) throw new Error(`db.migrate: the file ${entry.name} has no number: name it <number>_<name>.sql`);
48
+ migrations.push({ version: Number(match[1]), name: match[2]!, sql: await readFile(join(folder, entry.name), "utf8") });
49
+ }
50
+ return migrations;
51
+ }
52
+
53
+ /** The migrations in the order of their numbers. Two migrations with one number are an error. */
54
+ function ordered(migrations: DbMigration[]): Migration[] {
55
+ const byVersion = new Map<number, Migration>();
56
+ for (const migration of migrations) {
57
+ if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
58
+ throw new Error(`db.migrate: the version ${migration.version} is not a whole number of 1 or more`);
59
+ }
60
+ if (byVersion.has(migration.version)) throw new Error(`db.migrate: two migrations have the version ${migration.version}`);
61
+ const name = migration.name ?? `migration ${migration.version}`;
62
+ byVersion.set(migration.version, { version: migration.version, name, sql: migration.sql, checksum: checksum(migration.sql) });
63
+ }
64
+ return [...byVersion.values()].sort((a, b) => a.version - b.version);
65
+ }
66
+
67
+ /**
68
+ * Apply the numbered migrations that a database does not have
69
+ */
70
+ export async function dbMigrate(input: DbMigrateInput): Promise<DbMigrateOutput> {
71
+ if ((input.folder === undefined) === (input.migrations === undefined)) {
72
+ throw new Error("db.migrate: give exactly one of folder or migrations");
73
+ }
74
+ const table = input.table ?? DEFAULT_MIGRATION_TABLE;
75
+ if (!MIGRATION_TABLE_PATTERN.test(table)) throw new Error(`db.migrate: the table name ${JSON.stringify(table)} is not valid`);
76
+ const migrations = ordered(input.folder !== undefined ? await migrationsOfFolder(input.folder) : input.migrations!);
77
+
78
+ return withConnection({ dbPath: databasePath(input.dbPath) }, (db) => {
79
+ db.run("BEGIN");
80
+ try {
81
+ db.run(
82
+ `CREATE TABLE IF NOT EXISTS "${table}" (version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')))`,
83
+ );
84
+ const applied = new Map(
85
+ query<{ version: number; checksum: string }>(db, `SELECT version, checksum FROM "${table}"`).rows.map((row) => [row.version, row.checksum]),
86
+ );
87
+ for (const migration of migrations) {
88
+ const known = applied.get(migration.version);
89
+ if (known !== undefined && known !== migration.checksum) {
90
+ throw new Error(`db.migrate: the migration ${migration.version} (${migration.name}) changed after it was applied: its sha256 is different`);
91
+ }
92
+ }
93
+ const pending = migrations.filter((migration) => !applied.has(migration.version));
94
+ for (const migration of pending) {
95
+ const label = `the migration ${migration.version} (${migration.name})`;
96
+ try {
97
+ runStatements(db, migration.sql, [], label);
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ throw new Error(message.startsWith(label) ? `db.migrate: ${message}` : `db.migrate: ${label} failed: ${message}`);
101
+ }
102
+ db.run(`INSERT INTO "${table}" (version, name, checksum) VALUES (?, ?, ?)`, [migration.version, migration.name, migration.checksum]);
103
+ }
104
+ db.run("COMMIT");
105
+ const versions = [...applied.keys(), ...pending.map((migration) => migration.version)];
106
+ return {
107
+ applied: pending.map((migration) => ({ version: migration.version, name: migration.name })),
108
+ skipped: migrations.length - pending.length,
109
+ version: versions.length > 0 ? Math.max(...versions) : 0,
110
+ };
111
+ } catch (error) {
112
+ try {
113
+ db.run("ROLLBACK");
114
+ } catch {
115
+ // SQLite ended the transaction already
116
+ }
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`${message}. No migration was applied.`);
119
+ }
120
+ });
121
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * db.schema procedure (roadmap R44)
3
+ *
4
+ * The columns, the indexes and the foreign keys of a table (or the columns of a view). The
5
+ * procedure reads them with the table-valued PRAGMA functions, so the name of the table is a
6
+ * bound value, not a part of the SQL text. The connection is read only.
7
+ */
8
+
9
+ import { query, withConnection } from "@mark1russell7/docker-sqlite";
10
+ import type { DbColumnInfo, DbForeignKeyInfo, DbIndexInfo, DbSchemaInput, DbSchemaOutput } from "../../types.js";
11
+ import { databasePath, requireDatabaseFile } from "./shared.js";
12
+
13
+ interface ColumnRow {
14
+ name: string;
15
+ type: string;
16
+ notnull: number;
17
+ dflt_value: string | null;
18
+ pk: number;
19
+ }
20
+
21
+ interface IndexRow {
22
+ name: string;
23
+ unique: number;
24
+ origin: string;
25
+ partial: number;
26
+ }
27
+
28
+ interface ForeignKeyRow {
29
+ from: string;
30
+ table: string;
31
+ to: string | null;
32
+ on_update: string;
33
+ on_delete: string;
34
+ }
35
+
36
+ /**
37
+ * The columns and indexes of a table
38
+ */
39
+ export async function dbSchema(input: DbSchemaInput): Promise<DbSchemaOutput> {
40
+ const path = databasePath(input.dbPath);
41
+ requireDatabaseFile("db.schema", path);
42
+ return withConnection({ dbPath: path }, (db) => {
43
+ db.run("PRAGMA query_only = 1");
44
+ const found = query<{ type: "table" | "view"; sql: string | null }>(
45
+ db,
46
+ "SELECT type, sql FROM sqlite_master WHERE name = ? AND type IN ('table', 'view')",
47
+ [input.table],
48
+ ).rows[0];
49
+ if (!found) throw new Error(`db.schema: no table or view named ${JSON.stringify(input.table)}`);
50
+
51
+ const columns: DbColumnInfo[] = query<ColumnRow>(db, "SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info(?) ORDER BY cid", [input.table]).rows.map(
52
+ (row) => ({ name: row.name, type: row.type, notNull: row.notnull === 1, defaultValue: row.dflt_value, primaryKey: row.pk }),
53
+ );
54
+ const indexes: DbIndexInfo[] = query<IndexRow>(db, "SELECT name, \"unique\", origin, partial FROM pragma_index_list(?) ORDER BY name", [input.table]).rows.map(
55
+ (row) => ({
56
+ name: row.name,
57
+ unique: row.unique === 1,
58
+ origin: row.origin,
59
+ partial: row.partial === 1,
60
+ columns: query<{ name: string | null }>(db, "SELECT name FROM pragma_index_info(?) ORDER BY seqno", [row.name]).rows.map((column) => column.name ?? "<expression>"),
61
+ }),
62
+ );
63
+ const foreignKeys: DbForeignKeyInfo[] = query<ForeignKeyRow>(
64
+ db,
65
+ "SELECT \"from\", \"table\", \"to\", on_update, on_delete FROM pragma_foreign_key_list(?) ORDER BY id, seq",
66
+ [input.table],
67
+ ).rows.map((row) => ({ from: row.from, table: row.table, to: row.to, onUpdate: row.on_update, onDelete: row.on_delete }));
68
+
69
+ return { table: input.table, type: found.type, sql: found.sql, columns, indexes, foreignKeys };
70
+ });
71
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The helpers of the db.* procedures of roadmap R44.
3
+ */
4
+
5
+ import { existsSync } from "node:fs";
6
+ import type { Database } from "@mark1russell7/docker-sqlite";
7
+ import { DEFAULT_DB_PATH } from "../../types.js";
8
+
9
+ /** The database file of an input: `dbPath`, or the CLI log database. */
10
+ export function databasePath(dbPath: string | undefined): string {
11
+ return dbPath ?? DEFAULT_DB_PATH;
12
+ }
13
+
14
+ /**
15
+ * This function throws ENOENT for a database file that does not exist. A procedure that only
16
+ * reads uses it: sql.js opens a missing file as an empty database, so a wrong path gave no tables.
17
+ */
18
+ export function requireDatabaseFile(procedure: string, path: string): void {
19
+ if (path === ":memory:" || existsSync(path)) return;
20
+ const error = new Error(`${procedure}: the database file does not exist: ${path}`) as NodeJS.ErrnoException;
21
+ error.code = "ENOENT";
22
+ throw error;
23
+ }
24
+
25
+ /** The statements that control a transaction, which the procedures run themselves. */
26
+ const TRANSACTION_CONTROL = /^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
27
+
28
+ /** The SQL of a statement without its leading spaces and comments. */
29
+ function withoutLeadingComments(sql: string): string {
30
+ let rest = sql;
31
+ for (;;) {
32
+ const trimmed = rest.replace(/^\s+/, "");
33
+ if (trimmed.startsWith("--")) {
34
+ const end = trimmed.indexOf("\n");
35
+ rest = end === -1 ? "" : trimmed.slice(end + 1);
36
+ } else if (trimmed.startsWith("/*")) {
37
+ const end = trimmed.indexOf("*/");
38
+ rest = end === -1 ? "" : trimmed.slice(end + 2);
39
+ } else {
40
+ return trimmed;
41
+ }
42
+ }
43
+ }
44
+
45
+ /** True for a statement that starts, ends or names a transaction (BEGIN, COMMIT, SAVEPOINT, ...). */
46
+ export function controlsTransaction(sql: string): boolean {
47
+ return TRANSACTION_CONTROL.test(withoutLeadingComments(sql));
48
+ }
49
+
50
+ /** The result of one statement: the rows that it changed, and its rows when it gives rows. */
51
+ export interface StatementRun {
52
+ changes: number;
53
+ rows?: Record<string, unknown>[];
54
+ }
55
+
56
+ function totalChanges(db: Database): number {
57
+ return Number(db.exec("SELECT total_changes()")[0]?.values[0]?.[0] ?? 0);
58
+ }
59
+
60
+ /**
61
+ * This function runs the statements of one SQL text in order, and gives one result for the text.
62
+ * The params bind to the first statement, so a text with params must hold one statement. A
63
+ * statement that controls a transaction is refused: the caller owns the transaction.
64
+ */
65
+ export function runStatements(db: Database, sql: string, params: unknown[], label: string): StatementRun {
66
+ const before = totalChanges(db);
67
+ const result: StatementRun = { changes: 0 };
68
+ let count = 0;
69
+ const iterator = db.iterateStatements(sql);
70
+ try {
71
+ for (let step = iterator.next(); !step.done; step = iterator.next()) {
72
+ const statement = step.value;
73
+ try {
74
+ count++;
75
+ if (count > 1 && params.length > 0) {
76
+ throw new Error(`${label}: the params bind to one statement, and the SQL has several`);
77
+ }
78
+ if (controlsTransaction(statement.getSQL())) {
79
+ throw new Error(`${label}: the procedure runs the transaction: BEGIN, COMMIT, ROLLBACK, SAVEPOINT and RELEASE are refused`);
80
+ }
81
+ if (count === 1 && params.length > 0) statement.bind(params as never);
82
+ if (statement.getColumnNames().length > 0) {
83
+ const rows: Record<string, unknown>[] = [];
84
+ while (statement.step()) rows.push(statement.getAsObject() as Record<string, unknown>);
85
+ result.rows = rows;
86
+ } else {
87
+ while (statement.step()) {
88
+ // A statement without columns gives no rows: one step runs it
89
+ }
90
+ }
91
+ } finally {
92
+ statement.free();
93
+ }
94
+ }
95
+ } catch (error) {
96
+ // Read the iterator to its end: that frees its copy of the SQL (it only prepares the rest)
97
+ try {
98
+ while (!iterator.next().done) {
99
+ // Prepare only
100
+ }
101
+ } catch {
102
+ // A later statement did not prepare: the iterator ended
103
+ }
104
+ throw error;
105
+ }
106
+ if (count === 0) throw new Error(`${label}: the SQL has no statement`);
107
+ result.changes = totalChanges(db) - before;
108
+ return result;
109
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * db.tables procedure (roadmap R44)
3
+ *
4
+ * The tables and the views of a database, without the internal `sqlite_` tables. The connection
5
+ * is read only (PRAGMA query_only), and a missing file is an error: it does not make one.
6
+ */
7
+
8
+ import { query, withConnection } from "@mark1russell7/docker-sqlite";
9
+ import type { DbTableInfo, DbTablesInput, DbTablesOutput } from "../../types.js";
10
+ import { databasePath, requireDatabaseFile } from "./shared.js";
11
+
12
+ /**
13
+ * The tables and views of a database
14
+ */
15
+ export async function dbTables(input: DbTablesInput): Promise<DbTablesOutput> {
16
+ const path = databasePath(input.dbPath);
17
+ requireDatabaseFile("db.tables", path);
18
+ const types = input.views === false ? "('table')" : "('table', 'view')";
19
+ return withConnection({ dbPath: path }, (db) => {
20
+ db.run("PRAGMA query_only = 1");
21
+ const result = query<DbTableInfo>(
22
+ db,
23
+ `SELECT name, type, sql FROM sqlite_master WHERE type IN ${types} AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name`,
24
+ );
25
+ return { tables: result.rows.map((row) => ({ name: row.name, type: row.type, sql: row.sql ?? null })) };
26
+ });
27
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * db.transaction procedure (roadmap R44)
3
+ *
4
+ * The statements of the input run in order in one transaction. When one fails, the procedure
5
+ * rolls back the transaction and does not save the file: the database stays as it was. A
6
+ * statement that gives rows (SELECT, or RETURNING) gives them in its result, so a later
7
+ * statement can follow an earlier one in the same transaction.
8
+ */
9
+
10
+ import { withConnection } from "@mark1russell7/docker-sqlite";
11
+ import type { DbStatementResult, DbTransactionInput, DbTransactionOutput } from "../../types.js";
12
+ import { databasePath, runStatements } from "./shared.js";
13
+
14
+ /**
15
+ * Run statements in one transaction
16
+ */
17
+ export async function dbTransaction(input: DbTransactionInput): Promise<DbTransactionOutput> {
18
+ return withConnection({ dbPath: databasePath(input.dbPath) }, (db) => {
19
+ db.run("BEGIN");
20
+ const results: DbStatementResult[] = [];
21
+ try {
22
+ input.statements.forEach((statement, index) => {
23
+ const label = `db.transaction: statements[${index}]`;
24
+ try {
25
+ results.push(runStatements(db, statement.sql, statement.params ?? [], label));
26
+ } catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ throw new Error(message.startsWith(label) ? message : `${label}: ${message}`);
29
+ }
30
+ });
31
+ db.run("COMMIT");
32
+ } catch (error) {
33
+ try {
34
+ db.run("ROLLBACK");
35
+ } catch {
36
+ // SQLite ended the transaction already (an ON CONFLICT ROLLBACK)
37
+ }
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ throw new Error(`${message}. The transaction was rolled back.`);
40
+ }
41
+ return { results, changes: results.reduce((sum, result) => sum + result.changes, 0) };
42
+ });
43
+ }