@podosoft/podokit 1.1.2 → 1.1.5

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.
@@ -1,3 +1,4 @@
1
+ import type { Database as SqliteDatabase } from "bun:sqlite";
1
2
  import type { Pool } from "pg";
2
3
 
3
4
  type MigrationRow = Record<string, unknown>;
@@ -13,95 +14,17 @@ export interface AccountIssuerMigrationDatabase {
13
14
 
14
15
  export type AccountIssuerMigrationResult = "absent" | "current" | "migrated";
15
16
 
16
- const LEGACY_ACCOUNT_ISSUERS = new Map<string, string>([
17
- ["credential", "local:credential"],
18
- ["google", "https://accounts.google.com"],
19
- ["github", "local:oauth:github"],
20
- ["apple", "https://appleid.apple.com"],
21
- ["facebook", "https://www.facebook.com"],
22
- ["discord", "local:oauth:discord"],
23
- ["gitlab", "local:oauth:gitlab"],
24
- ["linkedin", "local:oauth:linkedin"],
25
- ["twitch", "local:oauth:twitch"],
26
- ["spotify", "local:oauth:spotify"],
27
- ["dropbox", "local:oauth:dropbox"],
28
- ["kakao", "local:oauth:kakao"],
29
- ["naver", "local:oauth:naver"],
30
- ["line", "https://access.line.me"],
31
- ["slack", "local:oauth:slack"],
32
- ["notion", "local:oauth:notion"],
33
- ["twitter", "local:oauth:twitter"],
34
- ["tiktok", "local:oauth:tiktok"],
35
- ["reddit", "local:oauth:reddit"],
36
- ["zoom", "local:oauth:zoom"],
37
- ["figma", "local:oauth:figma"],
38
- ["salesforce", "local:oauth:salesforce"],
39
- ["atlassian", "local:oauth:atlassian"],
40
- ["kick", "local:oauth:kick"],
41
- ]);
42
-
43
- /**
44
- * Resolve only identities Better Auth can migrate without consulting mutable or
45
- * unverified profile data. Microsoft is deliberately absent: its 1.7 identity
46
- * changes from `sub` to the directory `oid`, which needs a trusted external map.
47
- */
48
- export function legacyAccountIssuer(providerId: string): string | undefined {
49
- return LEGACY_ACCOUNT_ISSUERS.get(providerId);
50
- }
51
-
52
- function sqlLiteral(value: string): string {
53
- return `'${value.replaceAll("'", "''")}'`;
54
- }
55
-
56
- /** Keep the release that is still serving able to insert accounts during rollout. */
57
- export function legacyAccountIssuerTriggerSql(): string {
58
- const cases = [...LEGACY_ACCOUNT_ISSUERS.entries()]
59
- .map(([providerId, issuer]) => ` WHEN ${sqlLiteral(providerId)} THEN ${sqlLiteral(issuer)}`)
60
- .join("\n");
61
-
62
- return `
63
- CREATE OR REPLACE FUNCTION public.podokit_fill_legacy_account_issuer()
64
- RETURNS trigger
65
- LANGUAGE plpgsql
66
- AS $podokit$
67
- BEGIN
68
- IF NEW."issuer" IS NOT NULL THEN
69
- RETURN NEW;
70
- END IF;
71
-
72
- NEW."issuer" := CASE NEW."providerId"
73
- ${cases}
74
- ELSE NULL
75
- END;
76
-
77
- IF NEW."issuer" IS NULL THEN
78
- RAISE EXCEPTION 'Cannot infer Better Auth issuer for legacy provider %', NEW."providerId"
79
- USING ERRCODE = '23502',
80
- HINT = 'Add an explicit trusted issuer mapping before creating this account.';
81
- END IF;
82
-
83
- IF NEW."providerId" = 'credential' THEN
84
- NEW."accountId" := NEW."userId";
85
- END IF;
86
-
87
- RETURN NEW;
88
- END
89
- $podokit$;
90
-
91
- DROP TRIGGER IF EXISTS podokit_fill_legacy_account_issuer ON "account";
92
- CREATE TRIGGER podokit_fill_legacy_account_issuer
93
- BEFORE INSERT ON "account"
94
- FOR EACH ROW
95
- WHEN (NEW."issuer" IS NULL)
96
- EXECUTE FUNCTION public.podokit_fill_legacy_account_issuer();
97
- `;
98
- }
99
-
100
- function readCount(rows: readonly MigrationRow[], label: string): number {
17
+ function readCount(rows: readonly MigrationRow[]): number {
101
18
  const value = rows[0]?.count;
102
19
  if (typeof value === "number" && Number.isSafeInteger(value)) return value;
103
20
  if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
104
- throw new Error(`Cannot read ${label} count during the Better Auth account migration.`);
21
+ throw new Error("Cannot read account identity collision count");
22
+ }
23
+
24
+ function assertNoCollisions(count: number): void {
25
+ if (count > 0) {
26
+ throw new Error("Cannot upgrade Better Auth: duplicate providerId and accountId identities require manual resolution");
27
+ }
105
28
  }
106
29
 
107
30
  export function postgresAccountIssuerMigrationDatabase(pool: Pool): AccountIssuerMigrationDatabase {
@@ -119,138 +42,47 @@ export function postgresAccountIssuerMigrationDatabase(pool: Pool): AccountIssue
119
42
  };
120
43
  }
121
44
 
122
- /**
123
- * Upgrade a populated pre-1.7 Better Auth account table without guessing identity.
124
- *
125
- * The compatibility trigger is installed in the same transaction as the NOT NULL
126
- * constraint. Writes from the release that is still serving either receive the
127
- * exact 1.7 issuer or fail closed for an identity that requires a trusted manual
128
- * mapping; they can never create a corrupt empty issuer during the rollout.
129
- */
45
+ /** Relax the obsolete 1.7.0-1.7.2 issuer schema for Better Auth 1.7.3 and later. */
130
46
  export async function migrateLegacyAccountIssuers(
131
47
  database: AccountIssuerMigrationDatabase,
132
48
  ): Promise<AccountIssuerMigrationResult> {
133
49
  const client = await database.connect();
134
50
  let transactionOpen = false;
135
-
136
51
  try {
137
52
  await client.query("BEGIN");
138
53
  transactionOpen = true;
139
54
  await client.query("SELECT pg_advisory_xact_lock(1886350955, 1)");
140
-
141
55
  const tableRows = await client.query<{ exists: boolean }>(`
142
- SELECT EXISTS (
143
- SELECT 1
144
- FROM information_schema.tables
145
- WHERE table_schema = 'public' AND table_name = 'account'
146
- ) AS "exists"
56
+ SELECT to_regclass('"account"') IS NOT NULL AS "exists"
147
57
  `);
148
58
  if (tableRows[0]?.exists !== true) {
149
59
  await client.query("COMMIT");
150
60
  transactionOpen = false;
151
61
  return "absent";
152
62
  }
153
-
154
- const columnRows = await client.query<{ dataType: string; isNullable: "YES" | "NO" }>(`
155
- SELECT data_type AS "dataType", is_nullable AS "isNullable"
156
- FROM information_schema.columns
157
- WHERE table_schema = 'public' AND table_name = 'account' AND column_name = 'issuer'
158
- `);
159
- const column = columnRows[0];
160
- if (column && column.dataType !== "text" && column.dataType !== "character varying") {
161
- throw new Error(`Cannot migrate Better Auth account issuer from database type ${column.dataType}.`);
162
- }
163
-
164
- if (column?.isNullable === "NO") {
165
- const invalidRows = await client.query<MigrationRow>(`
166
- SELECT COUNT(*)::int AS "count"
167
- FROM "account"
168
- WHERE btrim("issuer") = ''
169
- `);
170
- if (readCount(invalidRows, "blank issuer") > 0) {
171
- throw new Error("Cannot continue: the Better Auth account table contains blank issuer values.");
172
- }
173
- await client.query(legacyAccountIssuerTriggerSql());
174
- await client.query("COMMIT");
175
- transactionOpen = false;
176
- return "current";
177
- }
178
-
179
- if (!column) {
180
- await client.query('ALTER TABLE "account" ADD COLUMN "issuer" text');
181
- }
182
-
183
- const providerRows = await client.query<{ providerId: string }>(`
184
- SELECT DISTINCT "providerId"
185
- FROM "account"
186
- WHERE "issuer" IS NULL
187
- ORDER BY "providerId"
188
- `);
189
- for (const { providerId } of providerRows) {
190
- const issuer = legacyAccountIssuer(providerId);
191
- if (!issuer) {
192
- throw new Error(
193
- `Cannot infer a trusted Better Auth issuer for legacy provider ${providerId}. ` +
194
- "Follow the Better Auth 1.7 account identity migration guide and provide a verified mapping.",
195
- );
196
- }
197
-
198
- if (providerId === "credential") {
199
- const mismatchRows = await client.query<MigrationRow>(`
200
- SELECT COUNT(*)::int AS "count"
201
- FROM "account"
202
- WHERE "issuer" IS NULL
203
- AND "providerId" = 'credential'
204
- AND "accountId" <> "userId"
205
- `);
206
- if (readCount(mismatchRows, "credential identity mismatch") > 0) {
207
- throw new Error(
208
- "Cannot migrate credential accounts whose accountId differs from the linked userId.",
209
- );
210
- }
211
- }
212
-
213
- await client.query(
214
- `UPDATE "account"
215
- SET "issuer" = $1,
216
- "accountId" = CASE WHEN "providerId" = 'credential' THEN "userId" ELSE "accountId" END
217
- WHERE "issuer" IS NULL AND "providerId" = $2`,
218
- [issuer, providerId],
219
- );
220
- }
221
-
222
- const invalidRows = await client.query<MigrationRow>(`
223
- SELECT COUNT(*)::int AS "count"
224
- FROM "account"
225
- WHERE "issuer" IS NULL OR btrim("issuer") = ''
226
- `);
227
- if (readCount(invalidRows, "unresolved issuer") > 0) {
228
- throw new Error("Cannot continue: one or more Better Auth accounts have no trusted issuer.");
229
- }
230
-
231
- const collisionRows = await client.query<MigrationRow>(`
232
- SELECT COUNT(*)::int AS "count"
233
- FROM (
234
- SELECT "issuer", "accountId"
235
- FROM "account"
236
- GROUP BY "issuer", "accountId"
237
- HAVING COUNT(*) > 1
63
+ await client.query('LOCK TABLE "account" IN SHARE ROW EXCLUSIVE MODE');
64
+ const collisions = await client.query<MigrationRow>(`
65
+ SELECT COUNT(*)::int AS "count" FROM (
66
+ SELECT "providerId", "accountId" FROM "account"
67
+ GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
238
68
  ) AS collisions
239
69
  `);
240
- if (readCount(collisionRows, "identity collision") > 0) {
241
- throw new Error(
242
- "Cannot continue: Better Auth accounts collide on the new issuer and accountId identity key.",
243
- );
70
+ assertNoCollisions(readCount(collisions));
71
+ const columns = await client.query<{ exists: boolean }>(`
72
+ SELECT EXISTS (
73
+ SELECT 1 FROM pg_attribute
74
+ WHERE attrelid = '"account"'::regclass AND attname = 'issuer' AND NOT attisdropped
75
+ ) AS "exists"
76
+ `);
77
+ await client.query('DROP TRIGGER IF EXISTS podokit_fill_legacy_account_issuer ON "account"');
78
+ await client.query('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
79
+ if (columns[0]?.exists === true) {
80
+ await client.query('ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL');
244
81
  }
245
-
246
- await client.query(legacyAccountIssuerTriggerSql());
247
- await client.query('ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL');
248
- await client.query(
249
- 'CREATE UNIQUE INDEX IF NOT EXISTS "account_issuer_accountId_uidx" ON "account" ("issuer", "accountId")',
250
- );
82
+ await client.query('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
251
83
  await client.query("COMMIT");
252
84
  transactionOpen = false;
253
- return "migrated";
85
+ return columns[0]?.exists === true ? "migrated" : "current";
254
86
  } catch (error: unknown) {
255
87
  if (transactionOpen) await client.query("ROLLBACK");
256
88
  throw error;
@@ -258,3 +90,34 @@ export async function migrateLegacyAccountIssuers(
258
90
  client.release();
259
91
  }
260
92
  }
93
+
94
+ /** SQLite follows the upstream cleanup: remove the unused issuer column. */
95
+ export function migrateSqliteLegacyAccountIssuers(database: SqliteDatabase): AccountIssuerMigrationResult {
96
+ database.run("BEGIN IMMEDIATE");
97
+ try {
98
+ const table = database.query<{ name: string }, []>(
99
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'account'",
100
+ ).get();
101
+ if (!table) {
102
+ database.run("COMMIT");
103
+ return "absent";
104
+ }
105
+ const collision = database.query<{ count: number }, []>(`
106
+ SELECT COUNT(*) AS count FROM (
107
+ SELECT "providerId", "accountId" FROM "account"
108
+ GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
109
+ )
110
+ `).get();
111
+ assertNoCollisions(readCount(collision ? [collision] : []));
112
+ const hasIssuer = database.query<{ name: string }, []>('PRAGMA table_info("account")').all()
113
+ .some((column) => column.name === "issuer");
114
+ database.run('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
115
+ if (hasIssuer) database.run('ALTER TABLE "account" DROP COLUMN "issuer"');
116
+ database.run('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
117
+ database.run("COMMIT");
118
+ return hasIssuer ? "migrated" : "current";
119
+ } catch (error: unknown) {
120
+ database.run("ROLLBACK");
121
+ throw error;
122
+ }
123
+ }
@@ -3,6 +3,7 @@ import { auth } from "./auth/auth";
3
3
  import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
4
4
  import {
5
5
  migrateLegacyAccountIssuers,
6
+ migrateSqliteLegacyAccountIssuers,
6
7
  postgresAccountIssuerMigrationDatabase,
7
8
  } from "./auth/account-issuer-migration";
8
9
  import { runSqliteMigrations } from "./database/sqlite-migrator";
@@ -11,6 +12,7 @@ async function runMigrations(): Promise<void> {
11
12
  if (postgresPool) {
12
13
  await migrateLegacyAccountIssuers(postgresAccountIssuerMigrationDatabase(postgresPool));
13
14
  }
15
+ if (sqliteDatabase) migrateSqliteLegacyAccountIssuers(sqliteDatabase);
14
16
 
15
17
  const authMigrations = await getMigrations(auth.options);
16
18
  await authMigrations.runMigrations();
@@ -9,11 +9,11 @@
9
9
  "dependencies": {
10
10
  "@podosoft/podokit-auth": "^0.4.0",
11
11
  "@podosoft/podokit-contracts": "^0.4.0",
12
- "better-auth": "^1.7.1",
12
+ "better-auth": "^1.7.7",
13
13
  "pg": "^8.12.0",
14
- "@better-auth/api-key": "^1.7.1",
15
- "@better-auth/passkey": "^1.7.1",
16
- "@better-auth/oauth-provider": "^1.7.1"
14
+ "@better-auth/api-key": "^1.7.7",
15
+ "@better-auth/passkey": "^1.7.7",
16
+ "@better-auth/oauth-provider": "^1.7.7"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/pg": "^8.11.10"
@@ -58,7 +58,7 @@
58
58
  "file": "AGENTS.md",
59
59
  "marker": "<!-- podokit:end:agents-modules -->",
60
60
  "optional": true,
61
- "text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n"
61
+ "text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n- Back up existing databases before upgrading authentication. Migrations check duplicate `(providerId, accountId)` identities and clean up obsolete issuer constraints; resolve reported collisions manually rather than guessing account ownership.\n"
62
62
  }
63
63
  ],
64
64
  "instructions": [
@@ -27,9 +27,13 @@ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
27
27
  expect(presigned.ok()).toBeTruthy();
28
28
  const pre = await presigned.json() as { url: string };
29
29
  expect(new URL(pre.url).pathname).toContain(key);
30
- const download = await ctx.get(pre.url);
31
- expect(download.ok()).toBeTruthy();
32
- expect(await download.text()).toBe("hello world");
30
+ // The URL is signed for the API's S3 endpoint. A containerized dev stack signs an
31
+ // in-network host, so download it only when the runner was given that endpoint too.
32
+ if (process.env.S3_ENDPOINT) {
33
+ const download = await ctx.get(pre.url);
34
+ expect(download.ok()).toBeTruthy();
35
+ expect(await download.text()).toBe("hello world");
36
+ }
33
37
  } finally {
34
38
  await ctx.dispose();
35
39
  }
@@ -8,26 +8,44 @@ Follow it so your changes match the project's conventions.
8
8
 
9
9
  A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
10
10
 
11
- - `apps/api` — **Elysia** backend (`{{projectName}}-api`), Bun.SQL + PostgreSQL, merged OpenAPI at `/api-docs`.
11
+ - `apps/api` — **Elysia** backend (`{{projectName}}-api`), Bun.SQL + `{{databaseProvider}}`, merged OpenAPI at `/api-docs`.
12
12
  - `apps/web` — **SvelteKit** frontend (`{{projectName}}-web`), Tailwind v4 + shadcn-svelte + typesafe-i18n.
13
- - `infra/` — Docker Compose (PostgreSQL/Redis) and example k3s manifests.
14
- - `.podokit/` — PodoKit's generation lockfile (see "PodoKit tooling" below). Do not edit by hand.
13
+ - `infra/` — Docker Compose references for external services and example k3s manifests.
14
+ - `.podokit/` — generation metadata plus application-owned development and deployment profiles.
15
15
  - **Todo example**: `apps/api/src/todos/` (Elysia plugin, Bun.SQL repository, and migration) and the todo UI in `apps/web/src/routes/` show the end-to-end patterns to follow.
16
16
 
17
17
  ## Setup & commands
18
18
 
19
+ Inspect active infrastructure with `podo provider list`. New-project defaults
20
+ are `database=postgres`, `cache=redis`, `object-storage=s3`, `events=redis`, and
21
+ `jobs=bullmq`. A simple single-process app can instead use `sqlite`, `memory`
22
+ cache/events, `local` files, and `local` jobs. Only the database has a creation
23
+ flag (`--database`); apply other choices using
24
+ `podo provider set <capability> <provider> --apply` before adding features.
25
+ Provider selection does not migrate or delete existing data.
26
+
19
27
  ```bash
20
28
  {{packageManager}} install
21
29
  cp .env.example .env
22
- docker compose -f infra/docker/docker-compose.yml up -d # PostgreSQL (+ Redis)
30
+ {{apiRun}} migration:run
23
31
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
24
32
  ```
25
33
 
34
+ For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
35
+ before migrating. Add `--profile cache` for Redis; Silo uses the overlay from
36
+ `object-storage-s3`. Skip external services entirely for the complete local
37
+ combination. `podo dev watch` is a separate container workflow and requires
38
+ Docker. SQLite and local files default to `./data/podokit.sqlite` and
39
+ `./data/files` relative to the API working directory; migrations and runtime
40
+ must use the same database path. Keep local providers at one API process.
41
+ Merge added module settings from `.env.example` into an existing `.env`.
42
+
26
43
  - **Ports: web = 5001, api = 5002.** The web app reaches the API only through its
27
44
  SvelteKit server proxy (`/api/*`), never with a direct browser `fetch`.
28
45
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
29
46
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
30
- - DB migrations (TypeORM migration layer only): `{{apiRun}} migration:run`.
47
+ - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
48
+ - When `auth` is installed, run `{{apiRun}} migrate:all` for Better Auth and application tables.
31
49
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
32
50
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
33
51
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
@@ -69,6 +87,7 @@ in `.podokit/files.lock` — respect it:
69
87
 
70
88
  Useful commands:
71
89
 
90
+ - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, storage, events, and jobs; set previews by default.
72
91
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
73
92
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
74
93
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
@@ -2,9 +2,47 @@
2
2
 
3
3
  Full-stack TypeScript app generated with [PodoKit](https://github.com/podosoft-dev/podokit).
4
4
 
5
- - `apps/api` — Bun 1.4 + Elysia API: schema-validated env, `/health` + `/health/ready`, a Bun.SQL Todo CRUD resource backed by PostgreSQL, merged OpenAPI at `/api-docs`, and a standard error envelope.
5
+ - `apps/api` — Bun 1.4 + Elysia API: schema-validated env, `/health` + `/health/ready`, a Bun.SQL Todo CRUD resource backed by PostgreSQL or SQLite, merged OpenAPI at `/api-docs`, and a standard error envelope.
6
6
  - `apps/web` — SvelteKit app (TailwindCSS v4, shadcn-svelte, typesafe-i18n) with a todo UI that talks to the API through a server-side proxy.
7
- - `infra/` — Docker Compose (PostgreSQL, Redis) and k3s manifests.
7
+ - `infra/` — Docker Compose references for external services and k3s manifests.
8
+
9
+ ## Infrastructure choices
10
+
11
+ Inspect the current selections with `{{packageExecutor}} @podosoft/podokit provider list`.
12
+ New projects use these defaults unless configured otherwise:
13
+
14
+ | Capability | Default | Lightweight alternative |
15
+ | --- | --- | --- |
16
+ | Database | `postgres` | `sqlite` |
17
+ | Cache | `redis` | `memory` |
18
+ | Object storage | `s3` | `local` |
19
+ | Events | `redis` | `memory` |
20
+ | Jobs | `bullmq` | `local` |
21
+
22
+ For a simple program, prototype, or desktop app, SQLite, memory cache/events,
23
+ local files, and local jobs can run inside one API process without PostgreSQL,
24
+ Redis, or S3 services. `create --database sqlite` selects only the database;
25
+ choose the other providers after creation:
26
+
27
+ ```bash
28
+ {{packageExecutor}} @podosoft/podokit provider set cache memory --apply
29
+ {{packageExecutor}} @podosoft/podokit provider set object-storage local --apply
30
+ {{packageExecutor}} @podosoft/podokit provider set events memory --apply
31
+ {{packageExecutor}} @podosoft/podokit provider set jobs local --apply
32
+ ```
33
+
34
+ Apply providers before adding features, then install dependencies and merge new
35
+ `.env.example` entries into `.env`. These commands install implementation modules
36
+ and configure them; they do not move or delete data. SQLite defaults to
37
+ `./data/podokit.sqlite`, and local files to `LOCAL_STORAGE_PATH=./data/files`,
38
+ relative to the API working directory. Memory cache/events are lost on restart;
39
+ the database, files, and local job records persist. Use absolute paths and back
40
+ up the database and files together for packaged or production apps.
41
+
42
+ Use the host-process setup below to avoid Docker. The containerized loop still
43
+ requires Docker. See the PodoKit
44
+ [runtime provider guide](https://github.com/podosoft-dev/podokit/blob/main/docs/providers.md)
45
+ for server settings, Silo/AWS S3 configuration, and provider switching.
8
46
 
9
47
  ## Getting started
10
48
 
@@ -57,17 +95,27 @@ through Compose Watch, including Vite HMR on the same portless browser origin.
57
95
 
58
96
  ### Alternative: host processes
59
97
 
60
- Use this loop when you want only the web and API processes on the host:
98
+ Use this loop for an app with SQLite and local providers, or when you want the
99
+ web and API processes on the host. Prepare the project first:
61
100
 
62
101
  ```bash
63
- # start local PostgreSQL + Redis
64
- docker compose -f infra/docker/docker-compose.yml up -d
65
-
66
- # run database migrations
102
+ {{packageManager}} install
103
+ cp .env.example .env
67
104
  {{apiRun}} migration:run
105
+ ```
106
+
107
+ For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
108
+ before migrating. Redis needs `--profile cache`; Silo needs the storage overlay
109
+ added by `object-storage-s3`. Skip those services for the complete local setup.
110
+
111
+ Run each service in a separate terminal from the project root:
68
112
 
69
- # run api + web
70
- {{packageManager}} run dev
113
+ ```bash
114
+ {{apiRun}} dev # terminal 1
115
+ ```
116
+
117
+ ```bash
118
+ {{webRun}} dev # terminal 2
71
119
  ```
72
120
 
73
121
  - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
@@ -78,14 +126,19 @@ tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podo
78
126
 
79
127
  ## Database & migrations
80
128
 
81
- The API uses Bun.SQL with PostgreSQL. TypeORM is limited to the migration layer;
82
- a sample Todo repository and initial migration are included.
129
+ The API uses Bun.SQL with the selected PostgreSQL or SQLite database. TypeORM
130
+ runs PostgreSQL migrations; SQLite uses the local migration runner. A sample
131
+ Todo repository and initial migration are included.
83
132
 
84
133
  ```bash
85
134
  {{apiRun}} migration:run # apply migrations
86
135
  {{apiRun}} migration:revert # roll back the last one
87
136
  ```
88
137
 
138
+ When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
139
+ Better Auth and application tables. Set the module's required environment
140
+ values before migrating.
141
+
89
142
  ## Deploy
90
143
 
91
144
  Docker Compose lives in `infra/docker`; the basic k3s manifests in `infra/k3s`
@@ -4,6 +4,17 @@ export class InitTodos1720100000000 implements MigrationInterface {
4
4
  name = "InitTodos1720100000000";
5
5
 
6
6
  async up(queryRunner: QueryRunner): Promise<void> {
7
+ if (queryRunner.connection.options.type === "better-sqlite3") {
8
+ await queryRunner.query(`
9
+ CREATE TABLE "todos" (
10
+ "id" text NOT NULL PRIMARY KEY,
11
+ "title" character varying(500) NOT NULL,
12
+ "completed" boolean NOT NULL DEFAULT false,
13
+ "createdAt" text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
14
+ )
15
+ `);
16
+ return;
17
+ }
7
18
  await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
8
19
  await queryRunner.query(`
9
20
  CREATE TABLE "todos" (
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { Database as SqliteDatabase } from "bun:sqlite";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import {
8
+ revertLatestSqliteMigration,
9
+ runSqliteMigrations,
10
+ type SqliteMigrationDefinition,
11
+ } from "../database/sqlite-migrator";
12
+ import { InitTodos1720100000000 } from "../migrations/1720100000000-InitTodos";
13
+ import { TodoRepository } from "./todo.repository";
14
+
15
+ const migrations: readonly SqliteMigrationDefinition[] = [
16
+ { timestamp: 1720100000000, migration: new InitTodos1720100000000() },
17
+ ];
18
+
19
+ describe("Todo SQLite persistence", () => {
20
+ it("preserves boolean and Date rows returned by PostgreSQL", async (): Promise<void> => {
21
+ const row = {
22
+ id: crypto.randomUUID(),
23
+ title: "Existing task",
24
+ completed: true,
25
+ createdAt: new Date("2026-10-07T00:00:00.000Z"),
26
+ };
27
+ const sql = ((): Promise<typeof row[]> => Promise.resolve([row])) as unknown as SQL;
28
+ expect(await new TodoRepository(sql).find(row.id)).toEqual({
29
+ ...row,
30
+ createdAt: "2026-10-07T00:00:00.000Z",
31
+ });
32
+ });
33
+
34
+ it("applies the initial migration once and reverts it", async (): Promise<void> => {
35
+ const database = new SqliteDatabase(":memory:");
36
+ try {
37
+ await runSqliteMigrations(database, migrations);
38
+ await runSqliteMigrations(database, migrations);
39
+ expect(database.query<{ count: number }, []>("SELECT count(*) AS count FROM migrations").get()?.count).toBe(1);
40
+ expect(database.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE name = 'todos'").get()?.name).toBe("todos");
41
+ expect(await revertLatestSqliteMigration(database, migrations)).toBe(true);
42
+ expect(database.query("SELECT name FROM sqlite_master WHERE name = 'todos'").get()).toBeNull();
43
+ } finally {
44
+ database.close();
45
+ }
46
+ });
47
+
48
+ it("creates, reads, updates, and removes records with stable API types", async (): Promise<void> => {
49
+ const directory = mkdtempSync(join(tmpdir(), "podokit-todo-"));
50
+ const path = join(directory, "podokit.sqlite");
51
+ const database = new SqliteDatabase(path);
52
+ let sql: SQL | undefined;
53
+ try {
54
+ await runSqliteMigrations(database, migrations);
55
+ sql = new SQL(`sqlite://${path}`, { adapter: "sqlite" });
56
+ const repository = new TodoRepository(sql);
57
+ const todo = await repository.create("First task");
58
+ expect(todo.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
59
+ expect(todo.completed).toBe(false);
60
+ expect(new Date(todo.createdAt).toISOString()).toBe(todo.createdAt);
61
+ expect(await repository.find(todo.id)).toEqual(todo);
62
+ expect(await repository.all()).toEqual([todo]);
63
+
64
+ const updated = await repository.update(todo.id, { title: "Completed task", completed: true });
65
+ expect(updated).toEqual({ ...todo, title: "Completed task", completed: true });
66
+ expect(await repository.remove(todo.id)).toBe(true);
67
+ expect(await repository.find(todo.id)).toBeNull();
68
+ expect(await repository.update(todo.id, { completed: false })).toBeNull();
69
+ expect(await repository.remove(todo.id)).toBe(false);
70
+ } finally {
71
+ await sql?.close();
72
+ database.close();
73
+ rmSync(directory, { recursive: true, force: true });
74
+ }
75
+ });
76
+ });
@@ -10,12 +10,16 @@ export interface TodoRecord {
10
10
  interface TodoRow {
11
11
  id: string;
12
12
  title: string;
13
- completed: boolean;
14
- createdAt: Date;
13
+ completed: boolean | number;
14
+ createdAt: Date | string;
15
15
  }
16
16
 
17
17
  function record(row: TodoRow): TodoRecord {
18
- return { ...row, createdAt: row.createdAt.toISOString() };
18
+ return {
19
+ ...row,
20
+ completed: Boolean(row.completed),
21
+ createdAt: (row.createdAt instanceof Date ? row.createdAt : new Date(row.createdAt)).toISOString(),
22
+ };
19
23
  }
20
24
 
21
25
  export class TodoRepository {
@@ -39,8 +43,9 @@ export class TodoRepository {
39
43
  }
40
44
 
41
45
  async create(title: string): Promise<TodoRecord> {
46
+ const id = crypto.randomUUID();
42
47
  const rows = await this.sql<TodoRow[]>`
43
- INSERT INTO todos (title) VALUES (${title})
48
+ INSERT INTO todos (id, title) VALUES (${id}, ${title})
44
49
  RETURNING id, title, completed, "createdAt"
45
50
  `;
46
51
  const row = rows[0];