@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.
Files changed (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. 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
- export async function createDatabaseClient(url = process.env.DATABASE_URL ?? 'file:./data/dev.sqlite'): Promise<DatabaseClient> {
11
- if (isSqlite(url)) {
12
- return createSqliteClient(url);
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
- function isSqlite(url: string): boolean {
23
- return url.startsWith('file:') || url.endsWith('.sqlite') || url.endsWith('.db');
24
- }
17
+ type BunSqlConstructor = new (url?: string) => BunSqlClient;
25
18
 
26
- function isPostgres(url: string): boolean {
27
- return url.startsWith('postgres://') || url.startsWith('postgresql://');
28
- }
19
+ type BunRuntime = {
20
+ SQL?: BunSqlConstructor;
21
+ };
29
22
 
30
- async function createSqliteClient(url: string): Promise<DatabaseClient> {
31
- let Database: typeof import('better-sqlite3');
32
- try {
33
- const sqliteModule = await import('better-sqlite3');
34
- Database = sqliteModule.default ?? (sqliteModule as unknown as typeof import('better-sqlite3'));
35
- } catch (error) {
36
- throw new Error(
37
- `[db] Failed to load better-sqlite3. Install it in your workspace with "npm install better-sqlite3". (${(error as Error).message})`
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 target = normalizeSqlitePath(url);
42
- mkdirSync(path.dirname(target), { recursive: true });
43
- const db = new Database(target);
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(sql, params) {
47
- const statement = db.prepare(sql);
48
- return statement.all(params ?? []);
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(sql, params) {
51
- const statement = db.prepare(sql);
52
- statement.run(params ?? []);
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
- db.close();
56
- }
70
+ await client.close();
71
+ },
57
72
  };
58
73
  }
59
74
 
60
- async function createPostgresClient(url: string): Promise<DatabaseClient> {
61
- type PgClientCtor = new (...args: any[]) => {
62
- query: (text: string, params?: unknown[]) => Promise<{ rows: any[] }>;
63
- connect: () => Promise<void>;
64
- end: () => Promise<void>;
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
- let ClientCtor: PgClientCtor;
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
- const pgModule = await import('pg');
70
- ClientCtor = (pgModule as unknown as { Client: PgClientCtor }).Client;
102
+ reason = String(Bun.version);
71
103
  } catch (error) {
72
- throw new Error(
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
- const client = new ClientCtor({ connectionString: url });
78
- await client.connect();
107
+ throw new Error(`[db] Failed to load Bun.SQL. Run database helpers with Bun. (${reason})`);
108
+ }
79
109
 
80
- return {
81
- async query(sql, params) {
82
- const result = await client.query(sql, params);
83
- return result.rows;
84
- },
85
- async execute(sql, params) {
86
- await client.query(sql, params);
87
- },
88
- async close() {
89
- await client.end();
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 normalizeSqlitePath(url: string): string {
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 path.resolve(url.slice('file:'.length));
137
+ return url.slice('file:'.length);
138
+ }
139
+ if (url.startsWith('sqlite://')) {
140
+ return url.slice('sqlite://'.length);
97
141
  }
98
- return path.resolve(url);
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 node
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
- await ensureMigrationsTable(client);
48
- if (direction === 'down') {
49
- await runDown(client, migrations, steps);
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(client: DatabaseClient, migrations: MigrationModule[], steps: number | undefined) {
59
- const applied = await getAppliedMigrations(client);
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
- await migration.up(createMigrationContext(client));
70
- await recordMigration(client, migration.id);
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(client: DatabaseClient, migrations: MigrationModule[], steps: number | undefined) {
75
- const applied = await getAppliedMigrations(client);
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
- await migration.down(createMigrationContext(client));
92
- await deleteMigrationRecord(client, id);
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 + `?t=${Date.now()}`;
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(exports: Record<string, unknown>, file: string): MigrationModule | undefined {
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 exports.default === 'object' && exports.default && typeof (exports.default as any).id === 'string'
156
- ? (exports.default as any).id
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
- : exports.default && typeof (exports.default as any).up === 'function'
162
- ? ((exports.default as any).up as MigrationFn)
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
- : exports.default && typeof (exports.default as any).down === 'function'
172
- ? ((exports.default as any).down as MigrationFn)
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
- npx tsx src/backend/db/migrate.ts [--list]
214
- npx tsx src/backend/db/migrate.ts --down [--steps 1]
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
- - Install 'better-sqlite3' for SQLite or 'pg' for Postgres before running.`);
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 'better-sqlite3';
1
+ declare module 'bun:sqlite';
2
2
  declare module 'pg';