@apisurf/ngn-persistence 0.2.7 → 0.2.8

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/client.d.ts CHANGED
@@ -30,6 +30,15 @@ declare function applyConnectionPragmas(client: Client, { wal, busyTimeoutMs }?:
30
30
  * there is one place that decides how a connection behaves under contention.
31
31
  */
32
32
  declare function openDbClient(url: string, options?: ConnectionPragmaOptions): Client;
33
+ /**
34
+ * Bring a file up to ngn's current schema. Returns the ids it applied.
35
+ *
36
+ * Everything pending goes in one write batch, so a file is never left halfway
37
+ * through a migration. If another process migrated the file between our read
38
+ * and our write, the batch fails on a table that already exists; reading again
39
+ * then finds nothing pending, and that is not an error.
40
+ */
41
+ declare function migrate(dbClient: Client): Promise<string[]>;
33
42
  declare function initDbFileIfNotExists(dbPath: DbPath): Promise<DbPath>;
34
43
  /**
35
44
  * Connect to DB from a file path or throw an error if the file does not exist.
@@ -40,12 +49,12 @@ declare function strictInitDbClientFromFilePath(sqliteUrl: `file:${string}`): Pr
40
49
  /**
41
50
  * Initialize a database client from a SQLite URL.
42
51
  * If the URL is ':memory:', it creates an in-memory database.
43
- * If the URL is a file path, it checks if the file exists and runs migrations if it does not to
44
- * ensure the DB file is there and ready to use.
52
+ * If the URL is a file path, the file is created when missing. Either way the
53
+ * database is migrated to ngn's current schema before it is returned.
45
54
  * @param sqliteUrl
46
55
  * @returns
47
56
  */
48
57
  declare function safeInitDbClient(sqliteUrl: DbPath): Promise<Client>;
49
58
  declare function isDbPath(dbPath: string): dbPath is DbPath;
50
59
 
51
- export { type ConnectionPragmaOptions, type DbPath, applyConnectionPragmas, initDbFileIfNotExists, isDbPath, openDbClient, safeInitDbClient, strictInitDbClientFromFilePath };
60
+ export { type ConnectionPragmaOptions, type DbPath, applyConnectionPragmas, initDbFileIfNotExists, isDbPath, migrate, openDbClient, safeInitDbClient, strictInitDbClientFromFilePath };
package/dist/client.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  import invariant from "tiny-invariant";
7
7
 
8
8
  // src/migration.ts
9
- var migrate1 = `
9
+ var initial = `
10
10
  CREATE TABLE IF NOT EXISTS file_tasks (
11
11
  id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
12
12
  path TEXT NOT NULL UNIQUE,
@@ -87,7 +87,28 @@ CREATE INDEX timings_file_task_id_idx ON timings(file_task_id);
87
87
  CREATE INDEX timings_task_run_id_idx ON timings(task_run_id);
88
88
  CREATE INDEX timings_created_at_idx ON timings(created_at);
89
89
  `;
90
- var migration = [migrate1];
90
+ var migrations = [
91
+ { id: "0001-initial", up: initial },
92
+ // What `task` returned, as JSON. NULL for a run that returned nothing or never finished.
93
+ { id: "0002-task-run-result", up: "ALTER TABLE task_runs ADD COLUMN result TEXT" },
94
+ // Named leases held by runs (ctx.lock, `exclusive`). A row past expires_at is free to take.
95
+ {
96
+ id: "0003-locks",
97
+ up: `CREATE TABLE IF NOT EXISTS locks (
98
+ name TEXT PRIMARY KEY NOT NULL,
99
+ holder TEXT NOT NULL,
100
+ task_run_id INTEGER,
101
+ acquired_at INTEGER NOT NULL,
102
+ expires_at INTEGER NOT NULL
103
+ )`
104
+ },
105
+ // A retry points at the run it retries (the first of its tick); a skip says why.
106
+ {
107
+ id: "0004-task-run-retry-and-skip-reason",
108
+ up: `ALTER TABLE task_runs ADD COLUMN retry_of INTEGER REFERENCES task_runs(id) ON DELETE CASCADE;
109
+ ALTER TABLE task_runs ADD COLUMN skip_reason TEXT`
110
+ }
111
+ ];
91
112
 
92
113
  // src/client.ts
93
114
  var BUSY_TIMEOUT_MS = 5e3;
@@ -117,10 +138,59 @@ function normalizeDbPath(dbPath) {
117
138
  const normalizedFilePath = dbPath.startsWith("file:") ? dbPath : `file:${dbPath}`;
118
139
  return normalizedFilePath;
119
140
  }
120
- async function runMigrations(dbClient) {
141
+ var MIGRATIONS_TABLE = "ngn_migrations";
142
+ function splitStatements(sql) {
143
+ return sql.split(";").map((stmt) => stmt.trim()).filter((stmt) => stmt.length > 0);
144
+ }
145
+ async function tableExists(dbClient, name) {
146
+ const { rows } = await dbClient.execute({
147
+ sql: "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
148
+ args: [name]
149
+ });
150
+ return rows.length > 0;
151
+ }
152
+ async function appliedMigrationIds(dbClient) {
153
+ if (await tableExists(dbClient, MIGRATIONS_TABLE)) {
154
+ const { rows } = await dbClient.execute(`SELECT id FROM ${MIGRATIONS_TABLE}`);
155
+ return new Set(rows.map((row) => row.id));
156
+ }
157
+ return new Set(await tableExists(dbClient, "file_tasks") ? [migrations[0].id] : []);
158
+ }
159
+ function migrationStatements(pending, applied) {
160
+ const statements = [
161
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
162
+ id TEXT PRIMARY KEY NOT NULL,
163
+ applied_at INTEGER NOT NULL DEFAULT(unixepoch('subsecond') * 1000)
164
+ )`
165
+ ];
166
+ for (const id of applied) {
167
+ statements.push({
168
+ sql: `INSERT OR IGNORE INTO ${MIGRATIONS_TABLE} (id) VALUES (?)`,
169
+ args: [id]
170
+ });
171
+ }
172
+ for (const migration of pending) {
173
+ statements.push(...splitStatements(migration.up));
174
+ statements.push({
175
+ sql: `INSERT INTO ${MIGRATIONS_TABLE} (id) VALUES (?)`,
176
+ args: [migration.id]
177
+ });
178
+ }
179
+ return statements;
180
+ }
181
+ async function migrate(dbClient) {
121
182
  invariant(dbClient, "dbClient is not initialized");
122
- const migrationCommands = migration[0].split(";").map((stmt) => stmt.trim()).filter((stmt) => stmt.length > 0);
123
- return dbClient.batch(migrationCommands, "write");
183
+ for (let attempt = 0; ; attempt++) {
184
+ const applied = await appliedMigrationIds(dbClient);
185
+ const pending = migrations.filter((migration) => !applied.has(migration.id));
186
+ if (pending.length === 0 && await tableExists(dbClient, MIGRATIONS_TABLE)) return [];
187
+ try {
188
+ await dbClient.batch(migrationStatements(pending, applied), "write");
189
+ return pending.map((migration) => migration.id);
190
+ } catch (error) {
191
+ if (attempt > 0) throw error;
192
+ }
193
+ }
124
194
  }
125
195
  async function initDbFileIfNotExists(dbPath) {
126
196
  invariant(dbPath, "dbPath is required");
@@ -129,7 +199,7 @@ async function initDbFileIfNotExists(dbPath) {
129
199
  const dbClient = openDbClient(normalizedDbPath);
130
200
  try {
131
201
  if (!dbFileExists) {
132
- await runMigrations(dbClient);
202
+ await migrate(dbClient);
133
203
  }
134
204
  } finally {
135
205
  dbClient.close();
@@ -152,11 +222,11 @@ async function safeInitDbClient(sqliteUrl) {
152
222
  let dbClient;
153
223
  if (sqliteUrl === ":memory:") {
154
224
  dbClient = openDbClient(sqliteUrl, { wal: false });
155
- await runMigrations(dbClient);
156
225
  } else {
157
226
  const normalizedDbPath = await initDbFileIfNotExists(sqliteUrl);
158
227
  dbClient = openDbClient(normalizedDbPath);
159
228
  }
229
+ await migrate(dbClient);
160
230
  return dbClient;
161
231
  }
162
232
  function isDbPath(dbPath) {
@@ -168,6 +238,7 @@ export {
168
238
  createClient,
169
239
  initDbFileIfNotExists,
170
240
  isDbPath,
241
+ migrate,
171
242
  openDbClient,
172
243
  safeInitDbClient,
173
244
  strictInitDbClientFromFilePath
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/ngn-persistence",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "ngn SQLite persistence: connection pragmas, migrations and the client.",
5
5
  "type": "module",
6
6
  "author": "Luka Vidakovic",
@@ -37,14 +37,16 @@
37
37
  "@libsql/client": "^0.14.0",
38
38
  "tiny-invariant": "^1.3.3",
39
39
  "zod": "^3.24.2",
40
- "@apisurf/ngn-os": "0.2.7"
40
+ "@apisurf/ngn-os": "0.2.8"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^22.8.1",
44
- "tsup": "^8.3.5"
44
+ "tsup": "^8.3.5",
45
+ "vitest": "4.1.8"
45
46
  },
46
47
  "scripts": {
47
48
  "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
48
50
  "build": "tsup src/client.ts --dts --out-dir dist --format esm",
49
51
  "dev": "tsup src/client.ts --dts --out-dir dist --format esm --watch"
50
52
  }