@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 +12 -3
- package/dist/client.js +78 -7
- package/package.json +5 -3
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,
|
|
44
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
}
|