@podosoft/podokit 1.4.0 → 1.4.1

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 CHANGED
@@ -66,7 +66,7 @@ bunx --bun @podosoft/podokit provider set events memory --apply
66
66
  bunx --bun @podosoft/podokit provider set jobs local --apply
67
67
  bun install
68
68
  cp .env.example .env
69
- bun run --cwd apps/api migration:run
69
+ bun run --cwd apps/api migrate:all
70
70
  ```
71
71
 
72
72
  Run `bun run --cwd apps/api dev` and `bun run --cwd apps/web dev` in separate
package/dist/lockfile.js CHANGED
@@ -57,6 +57,8 @@ exports.DEFAULT_OWNED_GLOBS = [
57
57
  // Owned additions to the managed /api proxy's header lists and body limits.
58
58
  "apps/web/src/lib/server/proxy-headers.ts",
59
59
  "apps/web/src/lib/server/body-limits.ts",
60
+ // Owned fallback page SvelteKit serves when the root layout or a hook fails.
61
+ "apps/web/src/error.html",
60
62
  // AI agent guidance — yours to customize; update never touches it.
61
63
  "AGENTS.md",
62
64
  "CLAUDE.md",
@@ -27,7 +27,7 @@ Provider selection does not migrate or delete existing data.
27
27
  ```bash
28
28
  {{packageManager}} install
29
29
  cp .env.example .env
30
- {{apiRun}} migration:run
30
+ {{apiRun}} migrate:all
31
31
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
32
32
  ```
33
33
 
@@ -50,10 +50,17 @@ Merge added module settings from `.env.example` into an existing `.env`.
50
50
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
51
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
52
  uploads larger than it must use a direct API path (`apiPaths`) instead.
53
+ - When the root layout or a server hook fails, such as with 503 while the API is
54
+ down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
55
+ with inline styles: no components, app CSS, scripts, or assets, and only the
56
+ `%sveltekit.status%` and `%sveltekit.error.message%` placeholders.
53
57
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
54
58
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
55
- - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
56
- - When `auth` is installed, run `{{apiRun}} migrate:all` for Better Auth and application tables.
59
+ - Set up the database and apply later migrations, including after adding a module, with
60
+ `{{apiRun}} migrate:all` (PostgreSQL or SQLite). With `auth` installed it applies the
61
+ Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
62
+ only the application migrations and never creates Better Auth tables;
63
+ `{{apiRun}} migration:revert` reverts the latest application migration.
57
64
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
58
65
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
59
66
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
@@ -82,7 +82,7 @@ Run lifecycle and container commands from a second terminal:
82
82
  {{packageExecutor}} @podosoft/podokit dev up -d # detached stack without source watching
83
83
  {{packageExecutor}} @podosoft/podokit dev ps
84
84
  {{packageExecutor}} @podosoft/podokit dev logs
85
- {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migration:run
85
+ {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migrate:all
86
86
  {{packageExecutor}} @podosoft/podokit dev down
87
87
  ```
88
88
 
@@ -113,7 +113,7 @@ web and API processes on the host. Prepare the project first:
113
113
  ```bash
114
114
  {{packageManager}} install
115
115
  cp .env.example .env
116
- {{apiRun}} migration:run
116
+ {{apiRun}} migrate:all
117
117
  ```
118
118
 
119
119
  For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
@@ -140,12 +140,15 @@ runs PostgreSQL migrations; SQLite uses the local migration runner. Add a
140
140
  numbered migration under `apps/api/src/migrations`, then run it:
141
141
 
142
142
  ```bash
143
- {{apiRun}} migration:run
143
+ {{apiRun}} migrate:all # set up the database and apply new migrations
144
+ {{apiRun}} migration:revert # roll back the last application migration
144
145
  ```
145
146
 
146
- When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
147
- Better Auth and application tables. Set the module's required environment
148
- values before migrating.
147
+ `migrate:all` is the one command for setup, after adding a module, and in
148
+ production. When `auth` is installed, it applies the Better Auth schema before
149
+ the application migrations, so set the module's required environment values
150
+ first. `{{apiRun}} migration:run` runs only the application migrations and
151
+ never creates Better Auth tables.
149
152
 
150
153
  Want a worked example? Generate the `todo` template instead:
151
154
  `{{packageExecutor}} @podosoft/podokit create my-app --template todo`.
@@ -9,7 +9,7 @@
9
9
  "lint": "bunx --bun tsc -p tsconfig.json --noEmit",
10
10
  "test": "bun test src",
11
11
  "contract": "bun scripts/verify-api-contract.ts",
12
- "migrate:all": "bun dist/database/migrate.js",
12
+ "migrate:all": "bun scripts/run-migrations.mjs",
13
13
  "typeorm": "bunx typeorm-ts-node-commonjs -d src/database/data-source.ts",
14
14
  "migration:run": "bun src/database/migrate.ts",
15
15
  "migration:revert": "bun src/database/migrate.ts --revert",
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Production images ship only the compiled output.
5
+ function entry(name) {
6
+ const compiled = new URL(`../dist/${name}.js`, import.meta.url);
7
+ if (process.env.NODE_ENV === "production" && existsSync(compiled)) return compiled;
8
+ const source = new URL(`../src/${name}.ts`, import.meta.url);
9
+ return existsSync(source) ? source : null;
10
+ }
11
+
12
+ // The auth module adds src/migrate.ts, which applies the Better Auth schema
13
+ // before it runs the application migrations.
14
+ const target = entry("migrate") ?? entry("database/migrate");
15
+ if (!target) {
16
+ process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
17
+ process.exit(1);
18
+ }
19
+ const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
20
+ stdin: "inherit",
21
+ stdout: "inherit",
22
+ stderr: "inherit",
23
+ });
24
+
25
+ process.exitCode = await subprocess.exited;
@@ -183,6 +183,22 @@ describe("isDatabaseUnavailable", () => {
183
183
  expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
184
184
  });
185
185
 
186
+ it("recognizes refused, dropped, and shut-down connections from pg and Bun SQL", () => {
187
+ const socket = (code: string): Error => Object.assign(new Error(`connect ${code}`), { code });
188
+ const server = (code: string): Error => Object.assign(new Error("server error"), { code });
189
+ for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"]) {
190
+ expect(isDatabaseUnavailable(socket(code))).toBe(true);
191
+ }
192
+ expect(isDatabaseUnavailable(new Error("Connection terminated unexpectedly"))).toBe(true);
193
+ for (const code of ["57P01", "57P02", "57P03", "08006", "08P01"]) {
194
+ expect(isDatabaseUnavailable(server(code))).toBe(true);
195
+ expect(
196
+ isDatabaseUnavailable(new SQL.PostgresError("server error", { code: "ERR_POSTGRES_SERVER_ERROR", errno: code })),
197
+ ).toBe(true);
198
+ }
199
+ expect(isDatabaseUnavailable(new Error("Failed to save", { cause: socket("ECONNREFUSED") }))).toBe(true);
200
+ });
201
+
186
202
  it("leaves query and application errors alone", () => {
187
203
  expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
188
204
  .toBe(false);
@@ -190,6 +206,8 @@ describe("isDatabaseUnavailable", () => {
190
206
  isDatabaseUnavailable(new SQL.PostgresError("duplicate key", { code: "ERR_POSTGRES_SERVER_ERROR", errno: "23505" })),
191
207
  ).toBe(false);
192
208
  expect(isDatabaseUnavailable(new Error("timeout"))).toBe(false);
209
+ expect(isDatabaseUnavailable(Object.assign(new Error("duplicate key"), { code: "23505" }))).toBe(false);
210
+ expect(isDatabaseUnavailable(Object.assign(new Error("not found"), { code: "ENOENT" }))).toBe(false);
193
211
  expect(isDatabaseUnavailable("ERR_POSTGRES_CONNECTION_TIMEOUT")).toBe(false);
194
212
  expect(isDatabaseUnavailable(undefined)).toBe(false);
195
213
  });
@@ -75,14 +75,36 @@ const UNAVAILABLE_SQL_CODES = new Set([
75
75
  const UNAVAILABLE_PG_MESSAGES = new Set([
76
76
  "timeout exceeded when trying to connect",
77
77
  "Connection terminated due to connection timeout",
78
+ "Connection terminated unexpectedly",
78
79
  "Query read timeout",
79
80
  ]);
81
+ // Socket errors from a pg client whose server is gone or unreachable.
82
+ const UNAVAILABLE_SOCKET_CODES = new Set([
83
+ "ECONNREFUSED",
84
+ "ECONNRESET",
85
+ "ETIMEDOUT",
86
+ "EHOSTUNREACH",
87
+ "ENETUNREACH",
88
+ "EPIPE",
89
+ ]);
90
+ // SQLSTATEs for a server shutting down or refusing connections (class 57P) and
91
+ // for broken connections (class 08); pg reports them as `code`, Bun SQL as `errno`.
92
+ const UNAVAILABLE_SQLSTATES = new Set(["57P01", "57P02", "57P03"]);
93
+
94
+ function unavailableSqlState(value: unknown): boolean {
95
+ return typeof value === "string" && (UNAVAILABLE_SQLSTATES.has(value) || /^08[0-9A-Z]{3}$/.test(value));
96
+ }
80
97
 
81
98
  /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
82
99
  export function isDatabaseUnavailable(error: unknown): boolean {
83
100
  let current: unknown = error;
84
101
  for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
85
- if (current instanceof SQL.PostgresError && UNAVAILABLE_SQL_CODES.has(current.code)) return true;
102
+ if (current instanceof SQL.PostgresError) {
103
+ if (UNAVAILABLE_SQL_CODES.has(current.code) || unavailableSqlState(current.errno)) return true;
104
+ } else {
105
+ const code = (current as { code?: unknown }).code;
106
+ if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
107
+ }
86
108
  if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
87
109
  current = current.cause;
88
110
  }
@@ -91,7 +91,7 @@ describe("PoolGuard", () => {
91
91
  await sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)`;
92
92
  await sql`INSERT INTO item ${sql([{ id: 1, name: "a" }, { id: 2, name: "b" }], "id", "name")}`;
93
93
  const filter = sql`WHERE "name" = ${"b"}`;
94
- expect(await sql`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
94
+ expect(await sql<Array<{ id: number }>>`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
95
95
  expect(await sql`SELECT count(*) AS n FROM item`.values()).toEqual([[2]]);
96
96
  const names = await sql.begin(async (tx) => {
97
97
  await tx`UPDATE item SET "name" = 'c' WHERE "id" = 1`;
@@ -119,7 +119,7 @@ describe("PoolGuard", () => {
119
119
  expect(server.pools.map((pool) => pool.closed())).toEqual([true, false]);
120
120
 
121
121
  server.stalled = false;
122
- expect(await guard.sql`SELECT 1`).toEqual([{ ok: 1 }]);
122
+ expect(await guard.sql<Array<{ ok: number }>>`SELECT 1`).toEqual([{ ok: 1 }]);
123
123
  });
124
124
 
125
125
  it("keeps a slow query running while the server answers", async () => {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const LAUNCHER = join(__dirname, "../../scripts/run-migrations.mjs");
7
+
8
+ interface Launch {
9
+ code: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }
13
+
14
+ /** Runs the migrate:all launcher in an API layout whose entries print their own path. */
15
+ async function launch(entries: string[], nodeEnv: string, body = ""): Promise<Launch> {
16
+ const root = mkdtempSync(join(tmpdir(), "run-migrations-"));
17
+ try {
18
+ mkdirSync(join(root, "scripts"));
19
+ cpSync(LAUNCHER, join(root, "scripts/run-migrations.mjs"));
20
+ for (const entry of entries) {
21
+ mkdirSync(dirname(join(root, entry)), { recursive: true });
22
+ writeFileSync(join(root, entry), `console.log(${JSON.stringify(entry)});\n${body}`);
23
+ }
24
+ const child = Bun.spawn([process.execPath, "scripts/run-migrations.mjs"], {
25
+ cwd: root,
26
+ env: { ...process.env, NODE_ENV: nodeEnv },
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ });
30
+ const [stdout, stderr, code] = await Promise.all([
31
+ new Response(child.stdout).text(),
32
+ new Response(child.stderr).text(),
33
+ child.exited,
34
+ ]);
35
+ return { code, stdout: stdout.trim(), stderr };
36
+ } finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ describe("migrate:all launcher", () => {
42
+ it("runs the application migrations from source in development", async () => {
43
+ const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "development");
44
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts" });
45
+ });
46
+
47
+ it("runs the compiled application migrations in production", async () => {
48
+ const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "production");
49
+ expect(result).toMatchObject({ code: 0, stdout: "dist/database/migrate.js" });
50
+ });
51
+
52
+ it("prefers the auth module's entry, which applies the Better Auth schema first", async () => {
53
+ expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development"))
54
+ .toMatchObject({ code: 0, stdout: "src/migrate.ts" });
55
+ // A production image has only the compiled output.
56
+ expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production"))
57
+ .toMatchObject({ code: 0, stdout: "dist/migrate.js" });
58
+ });
59
+
60
+ it("exits with the migration's status", async () => {
61
+ const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
62
+ expect(result.code).toBe(3);
63
+ });
64
+
65
+ it("fails when no migration entry exists", async () => {
66
+ const result = await launch([], "development");
67
+ expect(result.code).toBe(1);
68
+ expect(result.stderr).toContain("no migration entry in src or dist");
69
+ });
70
+ });
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>%sveltekit.status% %sveltekit.error.message%</title>
8
+ <!-- SvelteKit serves this page when the root layout or a server hook fails,
9
+ so it cannot use the app's styles, scripts, assets, or components. It
10
+ fills in only the status and the HTML-escaped error message. -->
11
+ <style>
12
+ :root {
13
+ --background: oklch(1 0 0);
14
+ --foreground: oklch(0.141 0.005 285.823);
15
+ --muted-foreground: oklch(0.552 0.016 285.938);
16
+ --border: oklch(0.92 0.004 286.32);
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --background: oklch(0.141 0.005 285.823);
22
+ --foreground: oklch(0.985 0 0);
23
+ --muted-foreground: oklch(0.705 0.015 286.067);
24
+ --border: oklch(1 0 0 / 15%);
25
+ }
26
+ }
27
+
28
+ body {
29
+ box-sizing: border-box;
30
+ display: grid;
31
+ min-height: 100vh;
32
+ margin: 0;
33
+ padding: 1.5rem;
34
+ place-items: center;
35
+ background: var(--background);
36
+ color: var(--foreground);
37
+ font-family: ui-sans-serif, system-ui, sans-serif;
38
+ line-height: 1.5;
39
+ }
40
+
41
+ main {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 1rem;
45
+ max-width: 32rem;
46
+ }
47
+
48
+ .status {
49
+ margin: 0;
50
+ color: var(--muted-foreground);
51
+ font-size: 2.25rem;
52
+ font-variant-numeric: tabular-nums;
53
+ font-weight: 600;
54
+ line-height: 1;
55
+ }
56
+
57
+ h1 {
58
+ margin: 0;
59
+ padding-left: 1rem;
60
+ border-left: 1px solid var(--border);
61
+ font-size: 1rem;
62
+ font-weight: 500;
63
+ overflow-wrap: anywhere;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main>
69
+ <p class="status">%sveltekit.status%</p>
70
+ <h1>%sveltekit.error.message%</h1>
71
+ </main>
72
+ </body>
73
+ </html>
@@ -4,11 +4,16 @@ import type { QueryRunner } from "typeorm";
4
4
  import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
5
5
  import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
6
6
 
7
- function runner(type: "postgres" | "better-sqlite3", queries: string[], invalid = false): QueryRunner {
7
+ function runner(
8
+ type: "postgres" | "better-sqlite3",
9
+ queries: string[],
10
+ { invalid = false, session = true }: { invalid?: boolean; session?: boolean } = {},
11
+ ): QueryRunner {
8
12
  return {
9
13
  connection: { options: { type } },
10
14
  query: (sql: string): Promise<unknown[]> => {
11
15
  queries.push(sql);
16
+ if (sql.includes("IS NOT NULL")) return Promise.resolve([{ exists: session }]);
12
17
  return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
13
18
  },
14
19
  } as unknown as QueryRunner;
@@ -25,6 +30,7 @@ function rows<Row>(database: Database, sql: string): Row[] {
25
30
  }
26
31
 
27
32
  const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
33
+ const SESSION_EXISTS = 'SELECT to_regclass($1) IS NOT NULL AS "exists"';
28
34
 
29
35
  describe("admin session list index migration", () => {
30
36
  it("builds the PostgreSQL index concurrently outside a transaction", async () => {
@@ -33,6 +39,7 @@ describe("admin session list index migration", () => {
33
39
  const up: string[] = [];
34
40
  await migration.up(runner("postgres", up));
35
41
  expect(up).toEqual([
42
+ SESSION_EXISTS,
36
43
  "SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
37
44
  CREATE,
38
45
  ]);
@@ -44,8 +51,30 @@ describe("admin session list index migration", () => {
44
51
 
45
52
  it("rebuilds an invalid index left by an interrupted build", async () => {
46
53
  const up: string[] = [];
47
- await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
48
- expect(up.slice(1)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
54
+ await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { invalid: true }));
55
+ expect(up.slice(2)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
56
+ });
57
+
58
+ // `migration:run` runs only the application migrations, so on a new database
59
+ // Better Auth has not created its tables yet.
60
+ it("fails with the command that creates the Better Auth tables on PostgreSQL", async () => {
61
+ const up: string[] = [];
62
+ await expect(new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { session: false })))
63
+ .rejects.toThrow('Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"');
64
+ expect(up).toEqual([SESSION_EXISTS]);
65
+ });
66
+
67
+ it("fails with the command that creates the Better Auth tables on SQLite and records nothing", async () => {
68
+ const database = new Database(":memory:");
69
+ try {
70
+ const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
71
+ await expect(runSqliteMigrations(database, migrations)).rejects.toThrow(
72
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"',
73
+ );
74
+ expect(rows(database, 'SELECT "name" FROM "migrations"')).toEqual([]);
75
+ } finally {
76
+ database.close();
77
+ }
49
78
  });
50
79
 
51
80
  it("serves the newest-first session page on SQLite and reverts", async () => {
@@ -5,6 +5,12 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
5
5
  // so without this index every page sorts the whole session table.
6
6
  const INDEX = "IDX_session_createdAt_id";
7
7
 
8
+ // Better Auth creates "session" in its own schema step. `migrate:all` runs that
9
+ // step before the application migrations; `migration:run` does not.
10
+ const MISSING_SESSION_TABLE =
11
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all", ' +
12
+ "which applies the Better Auth schema before the application migrations.";
13
+
8
14
  export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
15
  name = "IndexSessionCreatedAt1792060000000";
10
16
  // CONCURRENTLY cannot run inside a transaction block. The runner therefore
@@ -13,9 +19,21 @@ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
13
19
 
14
20
  async up(queryRunner: QueryRunner): Promise<void> {
15
21
  if (queryRunner.connection.options.type !== "postgres") {
16
- await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
22
+ try {
23
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
24
+ } catch (error) {
25
+ if (error instanceof Error && /no such table: (?:main\.)?session$/.test(error.message)) {
26
+ throw new Error(MISSING_SESSION_TABLE, { cause: error });
27
+ }
28
+ throw error;
29
+ }
17
30
  return;
18
31
  }
32
+ const [session] = await queryRunner.query(
33
+ `SELECT to_regclass($1) IS NOT NULL AS "exists"`,
34
+ [`"session"`],
35
+ ) as Array<{ exists: boolean } | undefined>;
36
+ if (!session?.exists) throw new Error(MISSING_SESSION_TABLE);
19
37
  // Sign-ins keep writing sessions while the index builds. An interrupted
20
38
  // build leaves an invalid index that IF NOT EXISTS would keep.
21
39
  const invalid = await queryRunner.query(
@@ -34,7 +34,7 @@
34
34
  }
35
35
  ],
36
36
  "instructions": [
37
- "Run migrations to create the audit_logs table: {{apiRun}} migration:run{{apiWorkspaceArg}}",
37
+ "Run migrations to create the audit_logs table: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
38
38
  "Auth/admin actions are recorded by a Better Auth hook. Record semantic application events with AuditService.recordRequest() or recordAudit(); use recordAuditLater() only for high-volume, non-security events.",
39
39
  "List entries newest first (admin session required): curl -i -b cookies.txt 'localhost:5002/audit-logs?limit=50' and pass the X-Next-Cursor header back as ?cursor= for the next page"
40
40
  ],
@@ -19,7 +19,6 @@
19
19
  "@types/pg": "^8.11.10"
20
20
  },
21
21
  "scripts": {
22
- "migrate:all": "bun scripts/run-migrations.mjs",
23
22
  "auth:configure": "bun scripts/configure-auth.mjs"
24
23
  },
25
24
  "env": [
@@ -77,12 +77,18 @@ test("chunked upload: parts, a retried part, resume status, and complete store t
77
77
  // Completing again returns the same result.
78
78
  expect(await (await ctx.post(`${uploads}/${upload.id}/complete`, { data: {} })).json()).toMatchObject({ key: result.key });
79
79
 
80
- // S3 returns an absolute presigned URL; local storage a signed API path.
81
- const download = await ctx.get(result.url.startsWith("/") ? `${apiBase}${result.url}` : result.url);
82
- expect(download.status()).toBe(200);
83
- const stored = await download.body();
84
- expect(stored.byteLength).toBe(file.byteLength);
85
- expect(sha256(stored)).toBe(sha256(file));
80
+ // Local storage returns a signed API path. S3 returns a URL signed for the API's
81
+ // S3 endpoint; a containerized dev stack signs an in-network host, so download it
82
+ // only when the runner was given that endpoint too. The completed size above
83
+ // comes from the stored object either way.
84
+ const local = result.url.startsWith("/");
85
+ if (local || process.env.S3_ENDPOINT) {
86
+ const download = await ctx.get(local ? `${apiBase}${result.url}` : result.url);
87
+ expect(download.status()).toBe(200);
88
+ const stored = await download.body();
89
+ expect(stored.byteLength).toBe(file.byteLength);
90
+ expect(sha256(stored)).toBe(sha256(file));
91
+ }
86
92
  } finally {
87
93
  await ctx.dispose();
88
94
  }
@@ -31,12 +31,12 @@
31
31
  "file": "AGENTS.md",
32
32
  "marker": "<!-- podokit:end:agents-modules -->",
33
33
  "optional": true,
34
- "text": "### file-upload\n- `POST /files` stores one multipart file under `uploads/<userId>/`; `GET /files/:key/url` presigns it for the owner or an administrator.\n- For large files use the resumable upload in the browser: `uploadFile(file, { onProgress, signal })` and `resumeUpload(id, file)` from `$lib/uploads`. They create a session (`POST /files/uploads`), send parts (`PUT /files/uploads/:id/parts/:n`, `application/octet-stream`, `Content-Length` equal to the part size; with `XMLHttpRequest` in the browser so `onProgress` reports bytes), retry failed parts honoring `Retry-After`, and complete (`POST /files/uploads/:id/complete`). `GET /files/uploads/:id` lists stored parts; `DELETE` cancels.\n- Upload sessions live in the database (`file_upload_sessions`, `file_upload_parts`), so any API replica serves any request; never keep upload state in memory. Run `{{apiRun}} migration:run{{apiWorkspaceArg}}` after adding the module.\n- Parts are larger than the web `BODY_SIZE_LIMIT`, so the gateway sends `/api/files/uploads/` straight to the API: `podo add` listed it in `.podokit/dev.json` `apiPaths` and each deployment profile's `exposure.apiPaths`, and `podo deploy init` adds it to new profiles. Keep it there.\n- POST bodies must be `application/json` and parts `application/octet-stream` (else 415 `UNSUPPORTED_MEDIA_TYPE`), and a browser `Origin` outside `CORS_ORIGIN` gets 403. Branch on `error.code`: `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_PART_SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `UPLOAD_COMPLETING`, `UPLOAD_EXPIRED`, `UPLOAD_ABORTED`.\n"
34
+ "text": "### file-upload\n- `POST /files` stores one multipart file under `uploads/<userId>/`; `GET /files/:key/url` presigns it for the owner or an administrator.\n- For large files use the resumable upload in the browser: `uploadFile(file, { onProgress, signal })` and `resumeUpload(id, file)` from `$lib/uploads`. They create a session (`POST /files/uploads`), send parts (`PUT /files/uploads/:id/parts/:n`, `application/octet-stream`, `Content-Length` equal to the part size; with `XMLHttpRequest` in the browser so `onProgress` reports bytes), retry failed parts honoring `Retry-After`, and complete (`POST /files/uploads/:id/complete`). `GET /files/uploads/:id` lists stored parts; `DELETE` cancels.\n- Upload sessions live in the database (`file_upload_sessions`, `file_upload_parts`), so any API replica serves any request; never keep upload state in memory. Run `{{apiRun}} migrate:all{{apiWorkspaceArg}}` after adding the module.\n- Parts are larger than the web `BODY_SIZE_LIMIT`, so the gateway sends `/api/files/uploads/` straight to the API: `podo add` listed it in `.podokit/dev.json` `apiPaths` and each deployment profile's `exposure.apiPaths`, and `podo deploy init` adds it to new profiles. Keep it there.\n- POST bodies must be `application/json` and parts `application/octet-stream` (else 415 `UNSUPPORTED_MEDIA_TYPE`), and a browser `Origin` outside `CORS_ORIGIN` gets 403. Branch on `error.code`: `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_PART_SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `UPLOAD_COMPLETING`, `UPLOAD_EXPIRED`, `UPLOAD_ABORTED`.\n"
35
35
  }
36
36
  ],
37
37
  "instructions": [
38
38
  "Configure the selected object-storage provider before uploading files.",
39
- "Run migrations to create the upload session tables: {{apiRun}} migration:run{{apiWorkspaceArg}}",
39
+ "Run migrations to create the upload session tables: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
40
40
  "Uploads are stored as uploads/<userId>/<uuid>-<name>. Add the auth module so every upload has an owner.",
41
41
  "Upload a file: curl -b cookies.txt -F 'file=@./some.png' localhost:5002/files",
42
42
  "The response includes { key, url } — an S3 presigned URL or a signed local download URL.",
@@ -27,7 +27,7 @@ Provider selection does not migrate or delete existing data.
27
27
  ```bash
28
28
  {{packageManager}} install
29
29
  cp .env.example .env
30
- {{apiRun}} migration:run
30
+ {{apiRun}} migrate:all
31
31
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
32
32
  ```
33
33
 
@@ -50,10 +50,17 @@ Merge added module settings from `.env.example` into an existing `.env`.
50
50
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
51
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
52
  uploads larger than it must use a direct API path (`apiPaths`) instead.
53
+ - When the root layout or a server hook fails, such as with 503 while the API is
54
+ down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
55
+ with inline styles: no components, app CSS, scripts, or assets, and only the
56
+ `%sveltekit.status%` and `%sveltekit.error.message%` placeholders.
53
57
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
54
58
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
55
- - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
56
- - When `auth` is installed, run `{{apiRun}} migrate:all` for Better Auth and application tables.
59
+ - Set up the database and apply later migrations, including after adding a module, with
60
+ `{{apiRun}} migrate:all` (PostgreSQL or SQLite). With `auth` installed it applies the
61
+ Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
62
+ only the application migrations and never creates Better Auth tables;
63
+ `{{apiRun}} migration:revert` reverts the latest application migration.
57
64
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
58
65
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
59
66
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
@@ -78,7 +78,7 @@ defaults in the example and credentials only in `.env.docker`.
78
78
  In a second terminal, apply the included Todo migration and use the lifecycle helpers:
79
79
 
80
80
  ```bash
81
- {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migration:run
81
+ {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migrate:all
82
82
  {{packageExecutor}} @podosoft/podokit dev url
83
83
  {{packageExecutor}} @podosoft/podokit dev up -d # detached stack without source watching
84
84
  {{packageExecutor}} @podosoft/podokit dev ps
@@ -113,7 +113,7 @@ web and API processes on the host. Prepare the project first:
113
113
  ```bash
114
114
  {{packageManager}} install
115
115
  cp .env.example .env
116
- {{apiRun}} migration:run
116
+ {{apiRun}} migrate:all
117
117
  ```
118
118
 
119
119
  For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
@@ -143,13 +143,15 @@ runs PostgreSQL migrations; SQLite uses the local migration runner. A sample
143
143
  Todo repository and initial migration are included.
144
144
 
145
145
  ```bash
146
- {{apiRun}} migration:run # apply migrations
147
- {{apiRun}} migration:revert # roll back the last one
146
+ {{apiRun}} migrate:all # set up the database and apply new migrations
147
+ {{apiRun}} migration:revert # roll back the last application migration
148
148
  ```
149
149
 
150
- When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
151
- Better Auth and application tables. Set the module's required environment
152
- values before migrating.
150
+ `migrate:all` is the one command for setup, after adding a module, and in
151
+ production. When `auth` is installed, it applies the Better Auth schema before
152
+ the application migrations, so set the module's required environment values
153
+ first. `{{apiRun}} migration:run` runs only the application migrations and
154
+ never creates Better Auth tables.
153
155
 
154
156
  ## Deploy
155
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,14 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
-
4
- const compiledEntry = new URL("../dist/migrate.js", import.meta.url);
5
- const sourceEntry = new URL("../src/migrate.ts", import.meta.url);
6
- const useCompiled = process.env.NODE_ENV === "production" && existsSync(compiledEntry);
7
- const entry = useCompiled ? compiledEntry : sourceEntry;
8
- const subprocess = Bun.spawn([process.execPath, fileURLToPath(entry)], {
9
- stdin: "inherit",
10
- stdout: "inherit",
11
- stderr: "inherit",
12
- });
13
-
14
- process.exitCode = await subprocess.exited;