@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 +1 -1
- package/dist/lockfile.js +2 -0
- package/dist/templates/fullstack/AGENTS.md +10 -3
- package/dist/templates/fullstack/README.md +9 -6
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +18 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +23 -1
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
- package/dist/templates/modules/file-upload/module.manifest.json +2 -2
- package/dist/templates/todo/AGENTS.md +10 -3
- package/dist/templates/todo/README.md +9 -7
- package/package.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
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
|
|
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}}
|
|
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
|
-
-
|
|
56
|
-
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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}}
|
|
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
|
],
|
|
@@ -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
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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
|
-
-
|
|
56
|
-
|
|
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}}
|
|
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}}
|
|
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}}
|
|
147
|
-
{{apiRun}} migration:revert # roll back the last
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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;
|