@webstir-io/webstir-backend 0.1.15 → 0.1.16
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/README.md +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
|
@@ -1,99 +1,224 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
3
|
|
|
4
|
+
import { resolveWorkspaceRoot } from '../env.js';
|
|
5
|
+
|
|
4
6
|
export interface DatabaseClient {
|
|
5
7
|
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
6
8
|
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
7
9
|
close(): Promise<void>;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (isPostgres(url)) {
|
|
15
|
-
return createPostgresClient(url);
|
|
16
|
-
}
|
|
17
|
-
throw new Error(
|
|
18
|
-
`[db] Unsupported DATABASE_URL '${url}'. Use file:./path/to.sqlite or postgres://...`
|
|
19
|
-
);
|
|
20
|
-
}
|
|
12
|
+
type BunSqlClient = {
|
|
13
|
+
unsafe<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;
|
|
14
|
+
close(options?: { timeout?: number }): Promise<void>;
|
|
15
|
+
};
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
return url.startsWith('file:') || url.endsWith('.sqlite') || url.endsWith('.db');
|
|
24
|
-
}
|
|
17
|
+
type BunSqlConstructor = new (url?: string) => BunSqlClient;
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
19
|
+
type BunRuntime = {
|
|
20
|
+
SQL?: BunSqlConstructor;
|
|
21
|
+
};
|
|
29
22
|
|
|
30
|
-
async function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
23
|
+
export async function createDatabaseClient(
|
|
24
|
+
url = process.env.DATABASE_URL ?? 'file:./data/dev.sqlite',
|
|
25
|
+
): Promise<DatabaseClient> {
|
|
26
|
+
const driver = detectDatabaseDriver(url);
|
|
27
|
+
const SQL = loadBunSql();
|
|
28
|
+
const normalizedUrl = driver === 'sqlite' ? normalizeSqliteUrl(url) : url;
|
|
29
|
+
|
|
30
|
+
if (driver === 'sqlite') {
|
|
31
|
+
ensureSqliteDirectory(normalizedUrl);
|
|
39
32
|
}
|
|
40
33
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
const client = new SQL(normalizedUrl);
|
|
35
|
+
return createBunSqlClient(client, driver);
|
|
36
|
+
}
|
|
44
37
|
|
|
38
|
+
function detectDatabaseDriver(url: string): 'sqlite' | 'postgres' {
|
|
39
|
+
const trimmed = url.trim();
|
|
40
|
+
if (
|
|
41
|
+
trimmed === ':memory:' ||
|
|
42
|
+
trimmed.startsWith('file:') ||
|
|
43
|
+
trimmed.startsWith('file://') ||
|
|
44
|
+
trimmed.startsWith('sqlite:') ||
|
|
45
|
+
trimmed.startsWith('sqlite://') ||
|
|
46
|
+
trimmed.endsWith('.sqlite') ||
|
|
47
|
+
trimmed.endsWith('.db')
|
|
48
|
+
) {
|
|
49
|
+
return 'sqlite';
|
|
50
|
+
}
|
|
51
|
+
if (trimmed.startsWith('postgres://') || trimmed.startsWith('postgresql://')) {
|
|
52
|
+
return 'postgres';
|
|
53
|
+
}
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[db] Unsupported DATABASE_URL '${url}'. Use file:./path/to.sqlite, sqlite:./path/to.sqlite, :memory:, or postgres://...`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createBunSqlClient(client: BunSqlClient, driver: 'sqlite' | 'postgres'): DatabaseClient {
|
|
45
60
|
return {
|
|
46
|
-
async query(
|
|
47
|
-
const
|
|
48
|
-
return
|
|
61
|
+
async query<T = unknown>(query: string, params?: unknown[]): Promise<T[]> {
|
|
62
|
+
const prepared = prepareQuery(query, params, driver);
|
|
63
|
+
return await client.unsafe<T>(prepared.query, prepared.params);
|
|
49
64
|
},
|
|
50
|
-
async execute(
|
|
51
|
-
const
|
|
52
|
-
|
|
65
|
+
async execute(query: string, params?: unknown[]): Promise<void> {
|
|
66
|
+
const prepared = prepareQuery(query, params, driver);
|
|
67
|
+
await client.unsafe(prepared.query, prepared.params);
|
|
53
68
|
},
|
|
54
69
|
async close() {
|
|
55
|
-
|
|
56
|
-
}
|
|
70
|
+
await client.close();
|
|
71
|
+
},
|
|
57
72
|
};
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
function prepareQuery(
|
|
76
|
+
query: string,
|
|
77
|
+
params: unknown[] | undefined,
|
|
78
|
+
driver: 'sqlite' | 'postgres',
|
|
79
|
+
): {
|
|
80
|
+
query: string;
|
|
81
|
+
params: unknown[] | undefined;
|
|
82
|
+
} {
|
|
83
|
+
if (!params || params.length === 0 || driver !== 'postgres' || !query.includes('?')) {
|
|
84
|
+
return { query, params };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
query: convertQuestionMarksToDollarParams(query),
|
|
89
|
+
params,
|
|
65
90
|
};
|
|
91
|
+
}
|
|
66
92
|
|
|
67
|
-
|
|
93
|
+
function loadBunSql(): BunSqlConstructor {
|
|
94
|
+
const bunRuntime = (globalThis as typeof globalThis & { Bun?: BunRuntime }).Bun;
|
|
95
|
+
const SQL = bunRuntime?.SQL;
|
|
96
|
+
if (SQL) {
|
|
97
|
+
return SQL;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let reason = 'missing Bun.SQL runtime';
|
|
68
101
|
try {
|
|
69
|
-
|
|
70
|
-
ClientCtor = (pgModule as unknown as { Client: PgClientCtor }).Client;
|
|
102
|
+
reason = String(Bun.version);
|
|
71
103
|
} catch (error) {
|
|
72
|
-
|
|
73
|
-
`[db] Failed to load pg. Install it in your workspace with "npm install pg". (${(error as Error).message})`
|
|
74
|
-
);
|
|
104
|
+
reason = (error as Error).message;
|
|
75
105
|
}
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
throw new Error(`[db] Failed to load Bun.SQL. Run database helpers with Bun. (${reason})`);
|
|
108
|
+
}
|
|
79
109
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
function normalizeSqliteUrl(url: string): string {
|
|
111
|
+
if (url.trim() === ':memory:') {
|
|
112
|
+
return ':memory:';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
116
|
+
const target = stripSqlitePrefix(url.trim());
|
|
117
|
+
const resolved = path.isAbsolute(target)
|
|
118
|
+
? path.resolve(target)
|
|
119
|
+
: path.resolve(workspaceRoot, target);
|
|
120
|
+
return `file:${resolved}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ensureSqliteDirectory(url: string): void {
|
|
124
|
+
if (url === ':memory:') {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const filename = stripSqlitePrefix(url);
|
|
129
|
+
mkdirSync(path.dirname(filename), { recursive: true });
|
|
92
130
|
}
|
|
93
131
|
|
|
94
|
-
function
|
|
132
|
+
function stripSqlitePrefix(url: string): string {
|
|
133
|
+
if (url.startsWith('file://')) {
|
|
134
|
+
return url.slice('file://'.length);
|
|
135
|
+
}
|
|
95
136
|
if (url.startsWith('file:')) {
|
|
96
|
-
return
|
|
137
|
+
return url.slice('file:'.length);
|
|
138
|
+
}
|
|
139
|
+
if (url.startsWith('sqlite://')) {
|
|
140
|
+
return url.slice('sqlite://'.length);
|
|
97
141
|
}
|
|
98
|
-
|
|
142
|
+
if (url.startsWith('sqlite:')) {
|
|
143
|
+
return url.slice('sqlite:'.length);
|
|
144
|
+
}
|
|
145
|
+
return url;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function convertQuestionMarksToDollarParams(query: string): string {
|
|
149
|
+
let result = '';
|
|
150
|
+
let placeholderIndex = 0;
|
|
151
|
+
let inSingleQuote = false;
|
|
152
|
+
let inDoubleQuote = false;
|
|
153
|
+
let inLineComment = false;
|
|
154
|
+
let inBlockComment = false;
|
|
155
|
+
|
|
156
|
+
for (let index = 0; index < query.length; index += 1) {
|
|
157
|
+
const character = query[index];
|
|
158
|
+
const next = query[index + 1];
|
|
159
|
+
|
|
160
|
+
if (inLineComment) {
|
|
161
|
+
result += character;
|
|
162
|
+
if (character === '\n') {
|
|
163
|
+
inLineComment = false;
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (inBlockComment) {
|
|
169
|
+
result += character;
|
|
170
|
+
if (character === '*' && next === '/') {
|
|
171
|
+
result += next;
|
|
172
|
+
index += 1;
|
|
173
|
+
inBlockComment = false;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!inSingleQuote && !inDoubleQuote && character === '-' && next === '-') {
|
|
179
|
+
result += character + next;
|
|
180
|
+
index += 1;
|
|
181
|
+
inLineComment = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!inSingleQuote && !inDoubleQuote && character === '/' && next === '*') {
|
|
186
|
+
result += character + next;
|
|
187
|
+
index += 1;
|
|
188
|
+
inBlockComment = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (character === "'" && !inDoubleQuote) {
|
|
193
|
+
result += character;
|
|
194
|
+
if (inSingleQuote && next === "'") {
|
|
195
|
+
result += next;
|
|
196
|
+
index += 1;
|
|
197
|
+
} else {
|
|
198
|
+
inSingleQuote = !inSingleQuote;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (character === '"' && !inSingleQuote) {
|
|
204
|
+
result += character;
|
|
205
|
+
if (inDoubleQuote && next === '"') {
|
|
206
|
+
result += next;
|
|
207
|
+
index += 1;
|
|
208
|
+
} else {
|
|
209
|
+
inDoubleQuote = !inDoubleQuote;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!inSingleQuote && !inDoubleQuote && character === '?') {
|
|
215
|
+
placeholderIndex += 1;
|
|
216
|
+
result += `$${placeholderIndex}`;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
result += character;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return result;
|
|
99
224
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -8,6 +8,7 @@ import type { DatabaseClient } from './connection.js';
|
|
|
8
8
|
|
|
9
9
|
const args = process.argv.slice(2);
|
|
10
10
|
const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'migrations');
|
|
11
|
+
const DEFAULT_MIGRATIONS_TABLE = '_webstir_migrations';
|
|
11
12
|
|
|
12
13
|
export type MigrationFn = (ctx: MigrationContext) => Promise<void> | void;
|
|
13
14
|
|
|
@@ -29,34 +30,41 @@ async function main() {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const migrations = await loadMigrations();
|
|
33
|
+
validateMigrationIds(migrations);
|
|
34
|
+
|
|
32
35
|
if (args.includes('--list')) {
|
|
33
36
|
printMigrations(migrations);
|
|
34
37
|
return;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
if (migrations.length === 0) {
|
|
38
|
-
console.warn('[migrate] No migrations found under src/backend/db/migrations');
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
40
|
const direction: 'up' | 'down' = args.includes('--down') ? 'down' : 'up';
|
|
43
41
|
const steps = parseSteps();
|
|
44
42
|
|
|
45
43
|
const client = await createDatabaseClient();
|
|
46
44
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const table = getMigrationsTable();
|
|
46
|
+
await ensureMigrationsTable(client, table);
|
|
47
|
+
if (args.includes('--status')) {
|
|
48
|
+
await printStatus(client, table, migrations);
|
|
49
|
+
} else if (migrations.length === 0) {
|
|
50
|
+
console.warn('[migrate] No migrations found under src/backend/db/migrations');
|
|
51
|
+
} else if (direction === 'down') {
|
|
52
|
+
await runDown(client, table, migrations, steps);
|
|
50
53
|
} else {
|
|
51
|
-
await runUp(client, migrations, steps);
|
|
54
|
+
await runUp(client, table, migrations, steps);
|
|
52
55
|
}
|
|
53
56
|
} finally {
|
|
54
57
|
await client.close();
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
async function runUp(
|
|
59
|
-
|
|
61
|
+
async function runUp(
|
|
62
|
+
client: DatabaseClient,
|
|
63
|
+
table: string,
|
|
64
|
+
migrations: MigrationModule[],
|
|
65
|
+
steps: number | undefined,
|
|
66
|
+
) {
|
|
67
|
+
const applied = await getAppliedMigrations(client, table);
|
|
60
68
|
const pending = migrations.filter((migration) => !applied.includes(migration.id));
|
|
61
69
|
if (pending.length === 0) {
|
|
62
70
|
console.info('[migrate] Database is up to date.');
|
|
@@ -66,13 +74,27 @@ async function runUp(client: DatabaseClient, migrations: MigrationModule[], step
|
|
|
66
74
|
const toRun = typeof steps === 'number' ? pending.slice(0, steps) : pending;
|
|
67
75
|
for (const migration of toRun) {
|
|
68
76
|
console.info(`[migrate] Applying ${migration.id}`);
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
try {
|
|
78
|
+
await runInTransaction(client, async () => {
|
|
79
|
+
await migration.up(createMigrationContext(client));
|
|
80
|
+
await recordMigration(client, table, migration.id);
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[migrate] Migration ${migration.id} failed while applying. The migration was rolled back and was not recorded as applied.`,
|
|
85
|
+
{ cause: error },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
async function runDown(
|
|
75
|
-
|
|
91
|
+
async function runDown(
|
|
92
|
+
client: DatabaseClient,
|
|
93
|
+
table: string,
|
|
94
|
+
migrations: MigrationModule[],
|
|
95
|
+
steps: number | undefined,
|
|
96
|
+
) {
|
|
97
|
+
const applied = await getAppliedMigrations(client, table);
|
|
76
98
|
if (applied.length === 0) {
|
|
77
99
|
console.info('[migrate] No applied migrations to roll back.');
|
|
78
100
|
return;
|
|
@@ -88,53 +110,96 @@ async function runDown(client: DatabaseClient, migrations: MigrationModule[], st
|
|
|
88
110
|
continue;
|
|
89
111
|
}
|
|
90
112
|
console.info(`[migrate] Reverting ${id}`);
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
try {
|
|
114
|
+
await runInTransaction(client, async () => {
|
|
115
|
+
await migration.down?.(createMigrationContext(client));
|
|
116
|
+
await deleteMigrationRecord(client, table, id);
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[migrate] Migration ${id} failed while reverting. The migration record was kept so it can be retried.`,
|
|
121
|
+
{ cause: error },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function printStatus(client: DatabaseClient, table: string, migrations: MigrationModule[]) {
|
|
128
|
+
const applied = await getAppliedMigrations(client, table);
|
|
129
|
+
const knownIds = new Set(migrations.map((migration) => migration.id));
|
|
130
|
+
const appliedIds = new Set(applied);
|
|
131
|
+
const pending = migrations.filter((migration) => !appliedIds.has(migration.id));
|
|
132
|
+
const missing = applied.filter((id) => !knownIds.has(id));
|
|
133
|
+
|
|
134
|
+
console.info(`[migrate] Status for ${table}:`);
|
|
135
|
+
console.info(`[migrate] Applied: ${applied.length}`);
|
|
136
|
+
console.info(`[migrate] Pending: ${pending.length}`);
|
|
137
|
+
if (pending.length > 0) {
|
|
138
|
+
for (const migration of pending) {
|
|
139
|
+
console.info(`- pending ${migration.id}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
console.warn('[migrate] Applied records without local migration files:');
|
|
144
|
+
for (const id of missing) {
|
|
145
|
+
console.warn(`- missing ${id}`);
|
|
146
|
+
}
|
|
93
147
|
}
|
|
94
148
|
}
|
|
95
149
|
|
|
96
|
-
async function ensureMigrationsTable(client: DatabaseClient) {
|
|
97
|
-
const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
|
|
150
|
+
async function ensureMigrationsTable(client: DatabaseClient, table: string) {
|
|
98
151
|
await client.execute(
|
|
99
152
|
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
100
153
|
id TEXT PRIMARY KEY,
|
|
101
154
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
102
|
-
)
|
|
155
|
+
)`,
|
|
103
156
|
);
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
async function getAppliedMigrations(client: DatabaseClient): Promise<string[]> {
|
|
107
|
-
const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
|
|
159
|
+
async function getAppliedMigrations(client: DatabaseClient, table: string): Promise<string[]> {
|
|
108
160
|
const rows = await client.query<{ id: string }>(`SELECT id FROM ${table} ORDER BY applied_at`);
|
|
109
161
|
return rows.map((row) => row.id);
|
|
110
162
|
}
|
|
111
163
|
|
|
112
|
-
async function recordMigration(client: DatabaseClient, id: string) {
|
|
113
|
-
const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
|
|
164
|
+
async function recordMigration(client: DatabaseClient, table: string, id: string) {
|
|
114
165
|
await client.execute(`INSERT INTO ${table} (id) VALUES (?)`, [id]);
|
|
115
166
|
}
|
|
116
167
|
|
|
117
|
-
async function deleteMigrationRecord(client: DatabaseClient, id: string) {
|
|
118
|
-
const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
|
|
168
|
+
async function deleteMigrationRecord(client: DatabaseClient, table: string, id: string) {
|
|
119
169
|
await client.execute(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
|
120
170
|
}
|
|
121
171
|
|
|
172
|
+
async function runInTransaction(client: DatabaseClient, action: () => Promise<void>) {
|
|
173
|
+
await client.execute('BEGIN');
|
|
174
|
+
try {
|
|
175
|
+
await action();
|
|
176
|
+
await client.execute('COMMIT');
|
|
177
|
+
} catch (error) {
|
|
178
|
+
try {
|
|
179
|
+
await client.execute('ROLLBACK');
|
|
180
|
+
} catch (rollbackError) {
|
|
181
|
+
throw new Error('[migrate] Failed to roll back failed migration transaction.', {
|
|
182
|
+
cause: rollbackError,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
122
189
|
function createMigrationContext(client: DatabaseClient): MigrationContext {
|
|
123
190
|
return {
|
|
124
191
|
sql: (query, params) => client.execute(query, params),
|
|
125
|
-
query: (query, params) => client.query(query, params)
|
|
192
|
+
query: (query, params) => client.query(query, params),
|
|
126
193
|
};
|
|
127
194
|
}
|
|
128
195
|
|
|
129
196
|
async function loadMigrations(): Promise<MigrationModule[]> {
|
|
130
197
|
try {
|
|
131
198
|
const files = await fs.readdir(MIGRATIONS_DIR);
|
|
132
|
-
const scriptFiles = files
|
|
133
|
-
.filter((file) => /\.[cm]?[jt]s$/.test(file))
|
|
134
|
-
.sort();
|
|
199
|
+
const scriptFiles = files.filter((file) => /\.[cm]?[jt]s$/.test(file)).sort();
|
|
135
200
|
const modules: MigrationModule[] = [];
|
|
136
201
|
for (const file of scriptFiles) {
|
|
137
|
-
const moduleUrl = pathToFileURL(path.join(MIGRATIONS_DIR, file)).href
|
|
202
|
+
const moduleUrl = `${pathToFileURL(path.join(MIGRATIONS_DIR, file)).href}?t=${Date.now()}`;
|
|
138
203
|
const imported = (await import(moduleUrl)) as Record<string, unknown>;
|
|
139
204
|
const migration = normalizeMigrationModule(imported, file);
|
|
140
205
|
if (migration) {
|
|
@@ -148,18 +213,25 @@ async function loadMigrations(): Promise<MigrationModule[]> {
|
|
|
148
213
|
}
|
|
149
214
|
}
|
|
150
215
|
|
|
151
|
-
function normalizeMigrationModule(
|
|
216
|
+
function normalizeMigrationModule(
|
|
217
|
+
exports: Record<string, unknown>,
|
|
218
|
+
file: string,
|
|
219
|
+
): MigrationModule | undefined {
|
|
220
|
+
const defaultExport =
|
|
221
|
+
typeof exports.default === 'object' && exports.default !== null
|
|
222
|
+
? (exports.default as Record<string, unknown>)
|
|
223
|
+
: undefined;
|
|
152
224
|
const id =
|
|
153
225
|
typeof exports.id === 'string'
|
|
154
226
|
? exports.id
|
|
155
|
-
: typeof
|
|
156
|
-
?
|
|
227
|
+
: typeof defaultExport?.id === 'string'
|
|
228
|
+
? defaultExport.id
|
|
157
229
|
: path.basename(file).replace(/\.[cm]?[jt]s$/, '');
|
|
158
230
|
const up: MigrationFn | undefined =
|
|
159
231
|
typeof exports.up === 'function'
|
|
160
232
|
? (exports.up as MigrationFn)
|
|
161
|
-
:
|
|
162
|
-
? (
|
|
233
|
+
: typeof defaultExport?.up === 'function'
|
|
234
|
+
? (defaultExport.up as MigrationFn)
|
|
163
235
|
: undefined;
|
|
164
236
|
if (!up) {
|
|
165
237
|
console.warn(`[migrate] ${file} does not export an up() function. Skipping.`);
|
|
@@ -168,12 +240,41 @@ function normalizeMigrationModule(exports: Record<string, unknown>, file: string
|
|
|
168
240
|
const down: MigrationFn | undefined =
|
|
169
241
|
typeof exports.down === 'function'
|
|
170
242
|
? (exports.down as MigrationFn)
|
|
171
|
-
:
|
|
172
|
-
? (
|
|
243
|
+
: typeof defaultExport?.down === 'function'
|
|
244
|
+
? (defaultExport.down as MigrationFn)
|
|
173
245
|
: undefined;
|
|
174
246
|
return { id, up, down };
|
|
175
247
|
}
|
|
176
248
|
|
|
249
|
+
function validateMigrationIds(migrations: MigrationModule[]) {
|
|
250
|
+
const seen = new Map<string, number>();
|
|
251
|
+
const duplicates = new Set<string>();
|
|
252
|
+
|
|
253
|
+
for (const migration of migrations) {
|
|
254
|
+
const count = seen.get(migration.id) ?? 0;
|
|
255
|
+
seen.set(migration.id, count + 1);
|
|
256
|
+
if (count > 0) {
|
|
257
|
+
duplicates.add(migration.id);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (duplicates.size > 0) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`[migrate] Duplicate migration id(s): ${Array.from(duplicates).sort().join(', ')}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getMigrationsTable(): string {
|
|
269
|
+
const table = process.env.DATABASE_MIGRATIONS_TABLE ?? DEFAULT_MIGRATIONS_TABLE;
|
|
270
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`[migrate] DATABASE_MIGRATIONS_TABLE must be a single SQL identifier using letters, numbers, and underscores, and must not start with a number (received "${table}").`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return table;
|
|
276
|
+
}
|
|
277
|
+
|
|
177
278
|
function parseSteps(): number | undefined {
|
|
178
279
|
const value = parseOption('--steps');
|
|
179
280
|
if (!value) return undefined;
|
|
@@ -210,19 +311,24 @@ function printMigrations(migrations: MigrationModule[]) {
|
|
|
210
311
|
|
|
211
312
|
function printHelp() {
|
|
212
313
|
console.info(`Usage:
|
|
213
|
-
|
|
214
|
-
|
|
314
|
+
bun src/backend/db/migrate.ts [--list]
|
|
315
|
+
bun src/backend/db/migrate.ts --status
|
|
316
|
+
bun src/backend/db/migrate.ts --down [--steps 1]
|
|
215
317
|
|
|
216
318
|
Options:
|
|
217
|
-
--list Show migrations and exit
|
|
319
|
+
--list Show local migrations and exit
|
|
320
|
+
--status Show applied, pending, and missing migration records
|
|
218
321
|
--down Roll back migrations instead of applying new ones
|
|
219
322
|
--steps <n> Limit how many migrations to run in the current direction
|
|
220
323
|
--help Show this message
|
|
221
324
|
|
|
222
325
|
Notes:
|
|
223
326
|
- Defaults to reading migration files from src/backend/db/migrations.
|
|
327
|
+
- DATABASE_MIGRATIONS_TABLE must be a single SQL identifier; it is validated before use.
|
|
328
|
+
- Each migration runs in a transaction. Failed up() migrations are rolled back and not recorded.
|
|
329
|
+
- For repeatable tests, use a throwaway DATABASE_URL and --down without --steps to run every available down() migration.
|
|
224
330
|
- DATABASE_URL controls the target database (file:./dev.sqlite by default).
|
|
225
|
-
-
|
|
331
|
+
- SQLite uses Bun's built-in bun:sqlite runtime; install 'pg' only for Postgres.`);
|
|
226
332
|
}
|
|
227
333
|
|
|
228
334
|
main().catch((error) => {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare module '
|
|
1
|
+
declare module 'bun:sqlite';
|
|
2
2
|
declare module 'pg';
|