@webstir-io/webstir-backend 0.1.15

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 (79) hide show
  1. package/README.md +427 -0
  2. package/dist/build/artifacts.d.ts +113 -0
  3. package/dist/build/artifacts.js +53 -0
  4. package/dist/build/entries.d.ts +1 -0
  5. package/dist/build/entries.js +17 -0
  6. package/dist/build/pipeline.d.ts +31 -0
  7. package/dist/build/pipeline.js +424 -0
  8. package/dist/cache/diff.d.ts +4 -0
  9. package/dist/cache/diff.js +114 -0
  10. package/dist/cache/reporters.d.ts +12 -0
  11. package/dist/cache/reporters.js +23 -0
  12. package/dist/diagnostics/summary.d.ts +6 -0
  13. package/dist/diagnostics/summary.js +27 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/manifest/pipeline.d.ts +13 -0
  17. package/dist/manifest/pipeline.js +224 -0
  18. package/dist/provider.d.ts +2 -0
  19. package/dist/provider.js +101 -0
  20. package/dist/scaffold/assets.d.ts +2 -0
  21. package/dist/scaffold/assets.js +77 -0
  22. package/dist/testing/context.d.ts +3 -0
  23. package/dist/testing/context.js +14 -0
  24. package/dist/testing/index.d.ts +6 -0
  25. package/dist/testing/index.js +208 -0
  26. package/dist/testing/types.d.ts +28 -0
  27. package/dist/testing/types.js +1 -0
  28. package/dist/watch.d.ts +8 -0
  29. package/dist/watch.js +159 -0
  30. package/dist/workspace.d.ts +4 -0
  31. package/dist/workspace.js +15 -0
  32. package/package.json +74 -0
  33. package/scripts/publish.sh +99 -0
  34. package/scripts/smoke.mjs +241 -0
  35. package/scripts/update-contract.sh +122 -0
  36. package/src/build/artifacts.ts +67 -0
  37. package/src/build/entries.ts +19 -0
  38. package/src/build/pipeline.ts +507 -0
  39. package/src/cache/diff.ts +128 -0
  40. package/src/cache/reporters.ts +41 -0
  41. package/src/diagnostics/summary.ts +32 -0
  42. package/src/index.ts +2 -0
  43. package/src/manifest/pipeline.ts +270 -0
  44. package/src/provider.ts +124 -0
  45. package/src/scaffold/assets.ts +81 -0
  46. package/src/testing/context.d.ts +3 -0
  47. package/src/testing/context.js +14 -0
  48. package/src/testing/context.ts +17 -0
  49. package/src/testing/index.d.ts +6 -0
  50. package/src/testing/index.js +208 -0
  51. package/src/testing/index.ts +252 -0
  52. package/src/testing/types.d.ts +28 -0
  53. package/src/testing/types.js +1 -0
  54. package/src/testing/types.ts +32 -0
  55. package/src/watch.ts +177 -0
  56. package/src/workspace.ts +22 -0
  57. package/templates/backend/.env.example +13 -0
  58. package/templates/backend/auth/adapter.ts +160 -0
  59. package/templates/backend/db/connection.ts +99 -0
  60. package/templates/backend/db/migrate.ts +231 -0
  61. package/templates/backend/db/migrations/0001-example.ts +17 -0
  62. package/templates/backend/db/types.d.ts +2 -0
  63. package/templates/backend/env.ts +174 -0
  64. package/templates/backend/functions/hello/index.ts +29 -0
  65. package/templates/backend/index.ts +532 -0
  66. package/templates/backend/jobs/nightly/index.ts +28 -0
  67. package/templates/backend/jobs/runtime.ts +103 -0
  68. package/templates/backend/jobs/scheduler.ts +193 -0
  69. package/templates/backend/module.ts +87 -0
  70. package/templates/backend/observability/logger.ts +24 -0
  71. package/templates/backend/observability/metrics.ts +78 -0
  72. package/templates/backend/server/fastify.ts +288 -0
  73. package/templates/backend/tsconfig.json +19 -0
  74. package/tests/cacheReporter.test.js +89 -0
  75. package/tests/envLoader.test.js +64 -0
  76. package/tests/integration.test.js +108 -0
  77. package/tests/manifest.test.js +159 -0
  78. package/tests/watch.test.js +100 -0
  79. package/tsconfig.json +27 -0
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+
3
+ import type { ResolvedModuleWorkspace } from '@webstir-io/module-contract';
4
+
5
+ export type BackendBuildMode = 'build' | 'publish' | 'test';
6
+
7
+ export function resolveWorkspacePaths(workspaceRoot: string): ResolvedModuleWorkspace {
8
+ return {
9
+ sourceRoot: path.join(workspaceRoot, 'src', 'backend'),
10
+ buildRoot: path.join(workspaceRoot, 'build', 'backend'),
11
+ testsRoot: path.join(workspaceRoot, 'src', 'backend', 'tests')
12
+ };
13
+ }
14
+
15
+ export function normalizeMode(rawMode: unknown): BackendBuildMode {
16
+ if (typeof rawMode !== 'string') {
17
+ return 'build';
18
+ }
19
+
20
+ const normalized = rawMode.toLowerCase();
21
+ return normalized === 'publish' || normalized === 'test' ? normalized : 'build';
22
+ }
@@ -0,0 +1,13 @@
1
+ NODE_ENV=development
2
+ PORT=4000
3
+ API_BASE_URL=http://localhost:4000
4
+ AUTH_JWT_SECRET=change-me
5
+ AUTH_JWT_ISSUER=https://example-idp/
6
+ AUTH_JWT_AUDIENCE=webstir-dev
7
+ AUTH_SERVICE_TOKENS=local-service-token
8
+ LOG_LEVEL=info
9
+ LOG_SERVICE_NAME=backend-template
10
+ METRICS_ENABLED=on
11
+ METRICS_WINDOW=200
12
+ DATABASE_URL=file:./data/dev.sqlite
13
+ DATABASE_MIGRATIONS_TABLE=_webstir_migrations
@@ -0,0 +1,160 @@
1
+ import crypto from 'node:crypto';
2
+ import type http from 'node:http';
3
+
4
+ import type { AuthSecrets } from '../env.js';
5
+
6
+ export interface AuthContext {
7
+ source: 'jwt' | 'service-token';
8
+ token: string;
9
+ userId?: string;
10
+ email?: string;
11
+ name?: string;
12
+ scopes: readonly string[];
13
+ roles: readonly string[];
14
+ claims: Record<string, unknown>;
15
+ }
16
+
17
+ export function resolveRequestAuth(
18
+ req: http.IncomingMessage,
19
+ secrets: AuthSecrets,
20
+ logger?: { warn?(message: string, metadata?: Record<string, unknown>): void }
21
+ ): AuthContext | undefined {
22
+ const bearer = getHeader(req, 'authorization');
23
+ if (bearer?.startsWith('Bearer ')) {
24
+ if (!secrets.jwtSecret) {
25
+ logger?.warn?.('Authorization header provided but AUTH_JWT_SECRET is not configured.');
26
+ } else {
27
+ const token = bearer.slice(7).trim();
28
+ const context = verifyJwtToken(token, secrets);
29
+ if (context) {
30
+ return context;
31
+ }
32
+ logger?.warn?.('Bearer token validation failed', { reason: 'invalid_token' });
33
+ }
34
+ }
35
+
36
+ const serviceToken = getHeader(req, 'x-service-token') ?? getHeader(req, 'x-api-key');
37
+ if (serviceToken && secrets.serviceTokens.length > 0) {
38
+ if (secrets.serviceTokens.includes(serviceToken)) {
39
+ return {
40
+ source: 'service-token',
41
+ token: serviceToken,
42
+ scopes: ['service'],
43
+ roles: ['service'],
44
+ claims: {},
45
+ userId: undefined,
46
+ email: undefined,
47
+ name: undefined
48
+ };
49
+ }
50
+ logger?.warn?.('Service token did not match any allowed AUTH_SERVICE_TOKENS entry');
51
+ }
52
+
53
+ return undefined;
54
+ }
55
+
56
+ function verifyJwtToken(token: string, secrets: AuthSecrets): AuthContext | undefined {
57
+ if (!secrets.jwtSecret) return undefined;
58
+ const parts = token.split('.');
59
+ if (parts.length !== 3) return undefined;
60
+ const [encodedHeader, encodedPayload, signature] = parts;
61
+
62
+ const header = decodeSegment(encodedHeader);
63
+ if (!header || header.alg !== 'HS256') {
64
+ return undefined;
65
+ }
66
+
67
+ const payload = decodeSegment(encodedPayload);
68
+ if (!payload) {
69
+ return undefined;
70
+ }
71
+
72
+ const signedContent = `${encodedHeader}.${encodedPayload}`;
73
+ const expectedSignature = crypto.createHmac('sha256', secrets.jwtSecret).update(signedContent).digest('base64url');
74
+ if (!timingSafeEqual(signature, expectedSignature)) {
75
+ return undefined;
76
+ }
77
+
78
+ if (secrets.jwtIssuer && payload.iss !== secrets.jwtIssuer) {
79
+ return undefined;
80
+ }
81
+
82
+ if (secrets.jwtAudience && !audienceMatches(payload.aud, secrets.jwtAudience)) {
83
+ return undefined;
84
+ }
85
+
86
+ const scopes = normalizeScopes(payload.scope);
87
+ const roles = normalizeRoles(payload.roles ?? payload.role ?? payload['https://schemas.webstir.dev/roles']);
88
+
89
+ return {
90
+ source: 'jwt',
91
+ token,
92
+ userId: typeof payload.sub === 'string' ? payload.sub : undefined,
93
+ email: typeof payload.email === 'string' ? payload.email : undefined,
94
+ name: typeof payload.name === 'string' ? payload.name : undefined,
95
+ scopes,
96
+ roles,
97
+ claims: payload as Record<string, unknown>
98
+ };
99
+ }
100
+
101
+ function decodeSegment(segment: string): Record<string, any> | undefined {
102
+ try {
103
+ const json = Buffer.from(segment, 'base64url').toString('utf8');
104
+ return JSON.parse(json) as Record<string, unknown>;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ function timingSafeEqual(left: string, right: string): boolean {
111
+ const leftBuffer = Buffer.from(left);
112
+ const rightBuffer = Buffer.from(right);
113
+ if (leftBuffer.length !== rightBuffer.length) {
114
+ return false;
115
+ }
116
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
117
+ }
118
+
119
+ function audienceMatches(value: unknown, expected: string): boolean {
120
+ if (Array.isArray(value)) {
121
+ return value.includes(expected);
122
+ }
123
+ if (typeof value === 'string') {
124
+ return value === expected;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ function normalizeScopes(value: unknown): string[] {
130
+ if (!value) return [];
131
+ if (Array.isArray(value)) {
132
+ return value.map((scope) => String(scope));
133
+ }
134
+ if (typeof value === 'string') {
135
+ return value.split(' ').map((scope) => scope.trim()).filter((scope) => scope.length > 0);
136
+ }
137
+ return [];
138
+ }
139
+
140
+ function normalizeRoles(value: unknown): string[] {
141
+ if (!value) return [];
142
+ if (Array.isArray(value)) {
143
+ return value.map((role) => String(role));
144
+ }
145
+ if (typeof value === 'string') {
146
+ return value.split(',').map((role) => role.trim()).filter((role) => role.length > 0);
147
+ }
148
+ return [];
149
+ }
150
+
151
+ function getHeader(req: http.IncomingMessage, name: string): string | undefined {
152
+ const value = req.headers[name] ?? req.headers[name.toLowerCase()];
153
+ if (typeof value === 'string') {
154
+ return value;
155
+ }
156
+ if (Array.isArray(value)) {
157
+ return value[0];
158
+ }
159
+ return undefined;
160
+ }
@@ -0,0 +1,99 @@
1
+ import path from 'node:path';
2
+ import { mkdirSync } from 'node:fs';
3
+
4
+ export interface DatabaseClient {
5
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
6
+ execute(sql: string, params?: unknown[]): Promise<void>;
7
+ close(): Promise<void>;
8
+ }
9
+
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
+ }
21
+
22
+ function isSqlite(url: string): boolean {
23
+ return url.startsWith('file:') || url.endsWith('.sqlite') || url.endsWith('.db');
24
+ }
25
+
26
+ function isPostgres(url: string): boolean {
27
+ return url.startsWith('postgres://') || url.startsWith('postgresql://');
28
+ }
29
+
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
+ );
39
+ }
40
+
41
+ const target = normalizeSqlitePath(url);
42
+ mkdirSync(path.dirname(target), { recursive: true });
43
+ const db = new Database(target);
44
+
45
+ return {
46
+ async query(sql, params) {
47
+ const statement = db.prepare(sql);
48
+ return statement.all(params ?? []);
49
+ },
50
+ async execute(sql, params) {
51
+ const statement = db.prepare(sql);
52
+ statement.run(params ?? []);
53
+ },
54
+ async close() {
55
+ db.close();
56
+ }
57
+ };
58
+ }
59
+
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>;
65
+ };
66
+
67
+ let ClientCtor: PgClientCtor;
68
+ try {
69
+ const pgModule = await import('pg');
70
+ ClientCtor = (pgModule as unknown as { Client: PgClientCtor }).Client;
71
+ } 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
+ );
75
+ }
76
+
77
+ const client = new ClientCtor({ connectionString: url });
78
+ await client.connect();
79
+
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
+ };
92
+ }
93
+
94
+ function normalizeSqlitePath(url: string): string {
95
+ if (url.startsWith('file:')) {
96
+ return path.resolve(url.slice('file:'.length));
97
+ }
98
+ return path.resolve(url);
99
+ }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+
6
+ import { createDatabaseClient } from './connection.js';
7
+ import type { DatabaseClient } from './connection.js';
8
+
9
+ const args = process.argv.slice(2);
10
+ const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'migrations');
11
+
12
+ export type MigrationFn = (ctx: MigrationContext) => Promise<void> | void;
13
+
14
+ interface MigrationModule {
15
+ id: string;
16
+ up: MigrationFn;
17
+ down?: MigrationFn;
18
+ }
19
+
20
+ export interface MigrationContext {
21
+ sql(query: string, params?: unknown[]): Promise<void>;
22
+ query<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;
23
+ }
24
+
25
+ async function main() {
26
+ if (args.includes('--help') || args.includes('-h')) {
27
+ printHelp();
28
+ return;
29
+ }
30
+
31
+ const migrations = await loadMigrations();
32
+ if (args.includes('--list')) {
33
+ printMigrations(migrations);
34
+ return;
35
+ }
36
+
37
+ if (migrations.length === 0) {
38
+ console.warn('[migrate] No migrations found under src/backend/db/migrations');
39
+ return;
40
+ }
41
+
42
+ const direction: 'up' | 'down' = args.includes('--down') ? 'down' : 'up';
43
+ const steps = parseSteps();
44
+
45
+ const client = await createDatabaseClient();
46
+ try {
47
+ await ensureMigrationsTable(client);
48
+ if (direction === 'down') {
49
+ await runDown(client, migrations, steps);
50
+ } else {
51
+ await runUp(client, migrations, steps);
52
+ }
53
+ } finally {
54
+ await client.close();
55
+ }
56
+ }
57
+
58
+ async function runUp(client: DatabaseClient, migrations: MigrationModule[], steps: number | undefined) {
59
+ const applied = await getAppliedMigrations(client);
60
+ const pending = migrations.filter((migration) => !applied.includes(migration.id));
61
+ if (pending.length === 0) {
62
+ console.info('[migrate] Database is up to date.');
63
+ return;
64
+ }
65
+
66
+ const toRun = typeof steps === 'number' ? pending.slice(0, steps) : pending;
67
+ for (const migration of toRun) {
68
+ console.info(`[migrate] Applying ${migration.id}`);
69
+ await migration.up(createMigrationContext(client));
70
+ await recordMigration(client, migration.id);
71
+ }
72
+ }
73
+
74
+ async function runDown(client: DatabaseClient, migrations: MigrationModule[], steps: number | undefined) {
75
+ const applied = await getAppliedMigrations(client);
76
+ if (applied.length === 0) {
77
+ console.info('[migrate] No applied migrations to roll back.');
78
+ return;
79
+ }
80
+
81
+ const toRollback = typeof steps === 'number' ? applied.slice(-steps) : applied;
82
+ const migrationMap = new Map(migrations.map((migration) => [migration.id, migration]));
83
+
84
+ for (const id of toRollback.reverse()) {
85
+ const migration = migrationMap.get(id);
86
+ if (!migration?.down) {
87
+ console.warn(`[migrate] Skipping ${id} (no down() function exported).`);
88
+ continue;
89
+ }
90
+ console.info(`[migrate] Reverting ${id}`);
91
+ await migration.down(createMigrationContext(client));
92
+ await deleteMigrationRecord(client, id);
93
+ }
94
+ }
95
+
96
+ async function ensureMigrationsTable(client: DatabaseClient) {
97
+ const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
98
+ await client.execute(
99
+ `CREATE TABLE IF NOT EXISTS ${table} (
100
+ id TEXT PRIMARY KEY,
101
+ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
102
+ )`
103
+ );
104
+ }
105
+
106
+ async function getAppliedMigrations(client: DatabaseClient): Promise<string[]> {
107
+ const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
108
+ const rows = await client.query<{ id: string }>(`SELECT id FROM ${table} ORDER BY applied_at`);
109
+ return rows.map((row) => row.id);
110
+ }
111
+
112
+ async function recordMigration(client: DatabaseClient, id: string) {
113
+ const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
114
+ await client.execute(`INSERT INTO ${table} (id) VALUES (?)`, [id]);
115
+ }
116
+
117
+ async function deleteMigrationRecord(client: DatabaseClient, id: string) {
118
+ const table = process.env.DATABASE_MIGRATIONS_TABLE ?? '_webstir_migrations';
119
+ await client.execute(`DELETE FROM ${table} WHERE id = ?`, [id]);
120
+ }
121
+
122
+ function createMigrationContext(client: DatabaseClient): MigrationContext {
123
+ return {
124
+ sql: (query, params) => client.execute(query, params),
125
+ query: (query, params) => client.query(query, params)
126
+ };
127
+ }
128
+
129
+ async function loadMigrations(): Promise<MigrationModule[]> {
130
+ try {
131
+ const files = await fs.readdir(MIGRATIONS_DIR);
132
+ const scriptFiles = files
133
+ .filter((file) => /\.[cm]?[jt]s$/.test(file))
134
+ .sort();
135
+ const modules: MigrationModule[] = [];
136
+ for (const file of scriptFiles) {
137
+ const moduleUrl = pathToFileURL(path.join(MIGRATIONS_DIR, file)).href + `?t=${Date.now()}`;
138
+ const imported = (await import(moduleUrl)) as Record<string, unknown>;
139
+ const migration = normalizeMigrationModule(imported, file);
140
+ if (migration) {
141
+ modules.push(migration);
142
+ }
143
+ }
144
+ return modules;
145
+ } catch (error) {
146
+ console.error('[migrate] Failed to load migrations:', (error as Error).message);
147
+ return [];
148
+ }
149
+ }
150
+
151
+ function normalizeMigrationModule(exports: Record<string, unknown>, file: string): MigrationModule | undefined {
152
+ const id =
153
+ typeof exports.id === 'string'
154
+ ? exports.id
155
+ : typeof exports.default === 'object' && exports.default && typeof (exports.default as any).id === 'string'
156
+ ? (exports.default as any).id
157
+ : path.basename(file).replace(/\.[cm]?[jt]s$/, '');
158
+ const up: MigrationFn | undefined =
159
+ typeof exports.up === 'function'
160
+ ? (exports.up as MigrationFn)
161
+ : exports.default && typeof (exports.default as any).up === 'function'
162
+ ? ((exports.default as any).up as MigrationFn)
163
+ : undefined;
164
+ if (!up) {
165
+ console.warn(`[migrate] ${file} does not export an up() function. Skipping.`);
166
+ return undefined;
167
+ }
168
+ const down: MigrationFn | undefined =
169
+ typeof exports.down === 'function'
170
+ ? (exports.down as MigrationFn)
171
+ : exports.default && typeof (exports.default as any).down === 'function'
172
+ ? ((exports.default as any).down as MigrationFn)
173
+ : undefined;
174
+ return { id, up, down };
175
+ }
176
+
177
+ function parseSteps(): number | undefined {
178
+ const value = parseOption('--steps');
179
+ if (!value) return undefined;
180
+ const parsed = Number(value);
181
+ if (!Number.isFinite(parsed) || parsed <= 0) {
182
+ throw new Error(`--steps must be a positive integer (received "${value}")`);
183
+ }
184
+ return Math.floor(parsed);
185
+ }
186
+
187
+ function parseOption(flag: string): string | undefined {
188
+ const index = args.indexOf(flag);
189
+ if (index !== -1 && args[index + 1] && !args[index + 1].startsWith('-')) {
190
+ return args[index + 1];
191
+ }
192
+ const prefix = `${flag}=`;
193
+ const inline = args.find((arg) => arg.startsWith(prefix));
194
+ if (inline) {
195
+ return inline.slice(prefix.length);
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ function printMigrations(migrations: MigrationModule[]) {
201
+ if (migrations.length === 0) {
202
+ console.info('[migrate] No migrations found.');
203
+ return;
204
+ }
205
+ console.info('[migrate] Available migrations:');
206
+ for (const migration of migrations) {
207
+ console.info(`- ${migration.id}${migration.down ? '' : ' (no down)'}`);
208
+ }
209
+ }
210
+
211
+ function printHelp() {
212
+ console.info(`Usage:
213
+ npx tsx src/backend/db/migrate.ts [--list]
214
+ npx tsx src/backend/db/migrate.ts --down [--steps 1]
215
+
216
+ Options:
217
+ --list Show migrations and exit
218
+ --down Roll back migrations instead of applying new ones
219
+ --steps <n> Limit how many migrations to run in the current direction
220
+ --help Show this message
221
+
222
+ Notes:
223
+ - Defaults to reading migration files from src/backend/db/migrations.
224
+ - DATABASE_URL controls the target database (file:./dev.sqlite by default).
225
+ - Install 'better-sqlite3' for SQLite or 'pg' for Postgres before running.`);
226
+ }
227
+
228
+ main().catch((error) => {
229
+ console.error('[migrate] Failed:', error);
230
+ process.exitCode = 1;
231
+ });
@@ -0,0 +1,17 @@
1
+ import type { MigrationContext } from '../migrate.js';
2
+
3
+ export const id = '0001_init';
4
+
5
+ export async function up(ctx: MigrationContext): Promise<void> {
6
+ await ctx.sql(`
7
+ CREATE TABLE IF NOT EXISTS example_records (
8
+ id TEXT PRIMARY KEY,
9
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ payload TEXT NOT NULL
11
+ )
12
+ `);
13
+ }
14
+
15
+ export async function down(ctx: MigrationContext): Promise<void> {
16
+ await ctx.sql(`DROP TABLE IF EXISTS example_records`);
17
+ }
@@ -0,0 +1,2 @@
1
+ declare module 'better-sqlite3';
2
+ declare module 'pg';