@podosoft/podokit 1.1.2 → 1.1.4
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 +60 -6
- package/dist/templates/base/AGENTS.md +9 -3
- package/dist/templates/base/README.md +12 -8
- package/dist/templates/fullstack/AGENTS.md +20 -2
- package/dist/templates/fullstack/README.md +65 -8
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +66 -148
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +60 -197
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +2 -0
- package/dist/templates/modules/auth/module.manifest.json +5 -5
- package/dist/templates/todo/AGENTS.md +24 -5
- package/dist/templates/todo/README.md +64 -11
- package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
- package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
- package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,11 +7,38 @@ through native `Bun.SQL`, Tailwind CSS v4, shadcn-svelte, Docker, and k3s assets
|
|
|
7
7
|
The CLI itself remains Node-compatible, so either `npx` or `bunx` can create and
|
|
8
8
|
maintain projects. Generated PodoKit v1 applications are Bun-only.
|
|
9
9
|
|
|
10
|
+
## Choose infrastructure
|
|
11
|
+
|
|
12
|
+
`fullstack` is the default template. `todo` adds a working CRUD example; `base`
|
|
13
|
+
is a minimal workspace with placeholder scripts. For a small working app, use
|
|
14
|
+
`fullstack` or `todo` with local providers rather than choosing `base` just to
|
|
15
|
+
avoid external infrastructure.
|
|
16
|
+
|
|
17
|
+
| Capability | Default | Local alternative |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| Database | `postgres` — PostgreSQL | `sqlite` — database file |
|
|
20
|
+
| Cache | `redis` | `memory` — in-process cache |
|
|
21
|
+
| Object storage | `s3` — AWS S3 or S3-compatible storage | `local` — local files |
|
|
22
|
+
| Events | `redis` | `memory` — in-process events |
|
|
23
|
+
| Jobs | `bullmq` — Redis queue and separate worker | `local` — database-backed embedded worker |
|
|
24
|
+
|
|
25
|
+
A simple program, prototype, desktop app, or single-process service can use
|
|
26
|
+
**SQLite, memory cache, and local files** without PostgreSQL, Redis, or S3.
|
|
27
|
+
Providers can be mixed. Local providers require one API process.
|
|
28
|
+
|
|
29
|
+
Only the database is selected during creation with `--database postgres|sqlite`.
|
|
30
|
+
Other capabilities use `provider set` after creation; there are no `--cache` or
|
|
31
|
+
`--storage` creation flags. Choosing SQLite alone leaves the other defaults
|
|
32
|
+
unchanged. Creation records selections, while applying a provider or adding a
|
|
33
|
+
feature installs the necessary optional implementation modules.
|
|
34
|
+
|
|
10
35
|
## Quick start
|
|
11
36
|
|
|
37
|
+
### Default server setup
|
|
38
|
+
|
|
12
39
|
```bash
|
|
13
40
|
npx @podosoft/podokit create my-app
|
|
14
|
-
# or: bunx @podosoft/podokit create my-app
|
|
41
|
+
# or: bunx --bun @podosoft/podokit create my-app
|
|
15
42
|
|
|
16
43
|
cd my-app
|
|
17
44
|
bun install
|
|
@@ -28,6 +55,26 @@ PostgreSQL or SQLite persistence:
|
|
|
28
55
|
|
|
29
56
|

|
|
30
57
|
|
|
58
|
+
### Small app with local providers
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bunx --bun @podosoft/podokit create podokit --template todo --database sqlite --yes
|
|
62
|
+
cd podokit
|
|
63
|
+
bunx --bun @podosoft/podokit provider set cache memory --apply
|
|
64
|
+
bunx --bun @podosoft/podokit provider set object-storage local --apply
|
|
65
|
+
bunx --bun @podosoft/podokit provider set events memory --apply
|
|
66
|
+
bunx --bun @podosoft/podokit provider set jobs local --apply
|
|
67
|
+
bun install
|
|
68
|
+
cp .env.example .env
|
|
69
|
+
bun run --cwd apps/api migration:run
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run `bun run --cwd apps/api dev` and `bun run --cwd apps/web dev` in separate
|
|
73
|
+
terminals and open `http://localhost:5001`. This host-process setup requires no
|
|
74
|
+
Docker. SQLite and local files default to `./data/podokit.sqlite` and
|
|
75
|
+
`./data/files`, relative to the API working directory. Memory state disappears
|
|
76
|
+
on restart; files and the database-backed local jobs persist.
|
|
77
|
+
|
|
31
78
|
## Commands
|
|
32
79
|
|
|
33
80
|
| Command | Purpose |
|
|
@@ -85,14 +132,21 @@ and [updating guide](https://github.com/podosoft-dev/podokit/blob/main/docs/upda
|
|
|
85
132
|
|
|
86
133
|
## Runtime providers
|
|
87
134
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
135
|
+
Inspect active selections and available alternatives with `podo provider list`.
|
|
136
|
+
`podo provider set` is a dry run by default and installs the selected
|
|
137
|
+
implementation module on `--apply`. Configure providers before adding features:
|
|
138
|
+
`file-upload` uses local storage when selected, and `job-progress` can use local
|
|
139
|
+
jobs and memory events. Changes append settings to `.env.example`; merge them
|
|
140
|
+
into an existing `.env` manually. Switching changes code and configuration only;
|
|
141
|
+
it never migrates or deletes data.
|
|
142
|
+
|
|
143
|
+
S3's development settings use Silo with `STORAGE_PROVIDER=minio`; use
|
|
144
|
+
`STORAGE_PROVIDER=aws` for AWS S3. Local files use `LOCAL_STORAGE_PATH`.
|
|
93
145
|
|
|
94
146
|
See the [runtime provider guide](https://github.com/podosoft-dev/podokit/blob/main/docs/providers.md)
|
|
95
147
|
before switching an existing project.
|
|
148
|
+
For the complete creation sequence, see
|
|
149
|
+
[Getting Started](https://github.com/podosoft-dev/podokit/blob/main/docs/getting-started.md).
|
|
96
150
|
|
|
97
151
|
## Compatibility boundary
|
|
98
152
|
|
|
@@ -5,19 +5,25 @@ by [PodoKit](https://github.com/podosoft-dev/podokit).
|
|
|
5
5
|
|
|
6
6
|
## Project overview
|
|
7
7
|
|
|
8
|
-
A minimal Bun 1.4 workspace starter
|
|
9
|
-
|
|
8
|
+
A minimal Bun 1.4 workspace starter with placeholder API/web scripts. Build your
|
|
9
|
+
own foundation here. Modules requiring the fullstack service registry, provider
|
|
10
|
+
consumers, or injection points cannot be added directly to this skeleton.
|
|
11
|
+
|
|
12
|
+
For a simple working full-stack app, create `fullstack` or `todo` with SQLite,
|
|
13
|
+
memory cache/events, local files, and local jobs. Those templates support local
|
|
14
|
+
providers independently of template choice and need one API process. See
|
|
15
|
+
[Getting Started](https://github.com/podosoft-dev/podokit/blob/main/docs/getting-started.md).
|
|
10
16
|
|
|
11
17
|
## Commands
|
|
12
18
|
|
|
13
19
|
```bash
|
|
14
20
|
{{packageManager}} install
|
|
15
|
-
{{rootRun}} dev # runs workspace dev scripts
|
|
16
21
|
{{rootRun}} build
|
|
17
22
|
{{rootRun}} lint # type-check
|
|
18
23
|
{{rootRun}} test
|
|
19
24
|
```
|
|
20
25
|
|
|
26
|
+
The generated scripts are placeholders; implement them before running the app.
|
|
21
27
|
## Code style
|
|
22
28
|
|
|
23
29
|
- TypeScript `strict`. **No `any`** (use `unknown` + narrowing), no `@ts-ignore`.
|
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Generated with [PodoKit](https://github.com/podosoft-dev/podokit).
|
|
4
4
|
|
|
5
|
-
A minimal Bun 1.4 TypeScript workspace
|
|
6
|
-
|
|
5
|
+
A minimal Bun 1.4 TypeScript workspace with placeholder API/web scripts. Choose
|
|
6
|
+
`fullstack` or `todo` when creating a project that needs the working Elysia API,
|
|
7
|
+
SvelteKit web app, database integration, or feature modules.
|
|
8
|
+
|
|
9
|
+
For a simple working app, those templates support SQLite, memory cache/events,
|
|
10
|
+
local files, and local jobs without external PostgreSQL, Redis, or S3 services.
|
|
11
|
+
`base` is for building your own foundation; it does not include the provider
|
|
12
|
+
consumers or module integration points. See
|
|
13
|
+
[Getting Started](https://github.com/podosoft-dev/podokit/blob/main/docs/getting-started.md).
|
|
7
14
|
|
|
8
15
|
## Getting started
|
|
9
16
|
|
|
10
17
|
```bash
|
|
11
18
|
{{packageManager}} install
|
|
12
|
-
{{packageManager}} run dev
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Copy the example environment file before running:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
19
|
cp .env.example .env
|
|
19
20
|
```
|
|
21
|
+
|
|
22
|
+
Implement the workspace scripts before running the application. The generated
|
|
23
|
+
`dev` commands are placeholders and do not start services.
|
|
@@ -16,18 +16,36 @@ A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
|
|
|
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
|
-
|
|
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
|
-
-
|
|
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,
|
|
@@ -2,9 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
Full-stack TypeScript starter 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`, merged OpenAPI at `/api-docs`, a standard error envelope, and Bun.SQL
|
|
5
|
+
- `apps/api` — Bun 1.4 + Elysia API: schema-validated env, `/health` + `/health/ready`, merged OpenAPI at `/api-docs`, a standard error envelope, and Bun.SQL with PostgreSQL or SQLite. TypeORM is used only for PostgreSQL migrations; SQLite has its own migration runner.
|
|
6
6
|
- `apps/web` — SvelteKit app (TailwindCSS v4, shadcn-svelte, typesafe-i18n) that talks to the API through a server-side proxy.
|
|
7
|
-
- `infra/` — Docker Compose
|
|
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,13 +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
|
|
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:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
{{packageManager}} install
|
|
103
|
+
cp .env.example .env
|
|
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:
|
|
61
112
|
|
|
62
113
|
```bash
|
|
63
|
-
|
|
64
|
-
|
|
114
|
+
{{apiRun}} dev # terminal 1
|
|
115
|
+
```
|
|
65
116
|
|
|
66
|
-
|
|
117
|
+
```bash
|
|
118
|
+
{{webRun}} dev # terminal 2
|
|
67
119
|
```
|
|
68
120
|
|
|
69
121
|
- API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
|
|
@@ -71,13 +123,18 @@ docker compose -f infra/docker/docker-compose.yml up -d
|
|
|
71
123
|
|
|
72
124
|
## Database & migrations
|
|
73
125
|
|
|
74
|
-
The API uses Bun.SQL
|
|
75
|
-
|
|
126
|
+
The API uses Bun.SQL with the selected PostgreSQL or SQLite database. TypeORM
|
|
127
|
+
runs PostgreSQL migrations; SQLite uses the local migration runner. Add a
|
|
128
|
+
numbered migration under `apps/api/src/migrations`, then run it:
|
|
76
129
|
|
|
77
130
|
```bash
|
|
78
131
|
{{apiRun}} migration:run
|
|
79
132
|
```
|
|
80
133
|
|
|
134
|
+
When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
|
|
135
|
+
Better Auth and application tables. Set the module's required environment
|
|
136
|
+
values before migrating.
|
|
137
|
+
|
|
81
138
|
Want a worked example? Generate the `todo` template instead:
|
|
82
139
|
`{{packageExecutor}} @podosoft/podokit create my-app --template todo`.
|
|
83
140
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dev": "bun --watch src/main.ts",
|
|
7
7
|
"build": "bun scripts/build.mjs",
|
|
8
8
|
"start": "bun dist/main.js",
|
|
9
|
-
"lint": "bunx tsc -p tsconfig.json --noEmit",
|
|
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
12
|
"migrate:all": "bun dist/database/migrate.js",
|
package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts
CHANGED
|
@@ -1,170 +1,88 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
3
|
import {
|
|
4
|
-
legacyAccountIssuer,
|
|
5
|
-
legacyAccountIssuerTriggerSql,
|
|
6
4
|
migrateLegacyAccountIssuers,
|
|
5
|
+
migrateSqliteLegacyAccountIssuers,
|
|
7
6
|
type AccountIssuerMigrationClient,
|
|
8
|
-
type AccountIssuerMigrationDatabase,
|
|
9
7
|
} from "./account-issuer-migration";
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
tableExists?: boolean;
|
|
14
|
-
column?: { dataType: string; isNullable: "YES" | "NO" };
|
|
15
|
-
counts?: {
|
|
16
|
-
blankIssuer?: number;
|
|
17
|
-
credentialMismatch?: number;
|
|
18
|
-
unresolvedIssuer?: number;
|
|
19
|
-
collision?: number;
|
|
20
|
-
};
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
class LegacyAccountClient implements AccountIssuerMigrationClient {
|
|
24
|
-
readonly statements: Statement[] = [];
|
|
9
|
+
class AccountClient implements AccountIssuerMigrationClient {
|
|
10
|
+
readonly statements: string[] = [];
|
|
25
11
|
released = false;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
async query<Row extends Record<string, unknown>>(
|
|
33
|
-
sql: string,
|
|
34
|
-
values?: readonly unknown[],
|
|
35
|
-
): Promise<readonly Row[]> {
|
|
36
|
-
this.statements.push({ sql, ...(values ? { values } : {}) });
|
|
37
|
-
if (sql.includes("information_schema.tables")) {
|
|
38
|
-
return [{ exists: this.options.tableExists ?? true }] as unknown as Row[];
|
|
39
|
-
}
|
|
40
|
-
if (sql.includes("information_schema.columns")) {
|
|
41
|
-
return (this.options.column ? [this.options.column] : []) as unknown as Row[];
|
|
42
|
-
}
|
|
43
|
-
if (sql.includes('SELECT DISTINCT "providerId"')) {
|
|
44
|
-
return this.providers.map((providerId) => ({ providerId })) as unknown as Row[];
|
|
45
|
-
}
|
|
46
|
-
if (sql.includes('COUNT(*)::int AS "count"')) {
|
|
47
|
-
const count = sql.includes('"accountId" <> "userId"')
|
|
48
|
-
? this.options.counts?.credentialMismatch
|
|
49
|
-
: sql.includes("AS collisions")
|
|
50
|
-
? this.options.counts?.collision
|
|
51
|
-
: sql.includes('"issuer" IS NULL OR')
|
|
52
|
-
? this.options.counts?.unresolvedIssuer
|
|
53
|
-
: this.options.counts?.blankIssuer;
|
|
54
|
-
return [{ count: count ?? 0 }] as unknown as Row[];
|
|
55
|
-
}
|
|
12
|
+
constructor(private readonly table = true, private readonly issuer = true, private readonly collisions = 0) {}
|
|
13
|
+
async query<Row extends Record<string, unknown>>(sql: string): Promise<readonly Row[]> {
|
|
14
|
+
this.statements.push(sql.trim());
|
|
15
|
+
if (sql.includes("to_regclass")) return [{ exists: this.table }] as unknown as Row[];
|
|
16
|
+
if (sql.includes("pg_attribute")) return [{ exists: this.issuer }] as unknown as Row[];
|
|
17
|
+
if (sql.includes("AS collisions")) return [{ count: this.collisions }] as unknown as Row[];
|
|
56
18
|
return [];
|
|
57
19
|
}
|
|
58
|
-
|
|
59
|
-
release(): void {
|
|
60
|
-
this.released = true;
|
|
61
|
-
}
|
|
20
|
+
release(): void { this.released = true; }
|
|
62
21
|
}
|
|
63
22
|
|
|
64
|
-
function
|
|
65
|
-
return { connect: async (): Promise<AccountIssuerMigrationClient> => client };
|
|
23
|
+
async function migrate(client: AccountClient): Promise<string> {
|
|
24
|
+
return migrateLegacyAccountIssuers({ connect: async (): Promise<AccountIssuerMigrationClient> => client });
|
|
66
25
|
}
|
|
67
26
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(legacyAccountIssuer("github")).toBe("local:oauth:github");
|
|
76
|
-
expect(legacyAccountIssuer("microsoft")).toBeUndefined();
|
|
77
|
-
expect(legacyAccountIssuer("unknown-provider")).toBeUndefined();
|
|
78
|
-
|
|
79
|
-
for (const { id } of SUPPORTED_SOCIAL_PROVIDERS) {
|
|
80
|
-
if (id !== "microsoft") expect(legacyAccountIssuer(id), id).toBeDefined();
|
|
81
|
-
}
|
|
82
|
-
});
|
|
27
|
+
function legacyDatabase(): Database {
|
|
28
|
+
const database = new Database(":memory:");
|
|
29
|
+
database.run('CREATE TABLE account (id text PRIMARY KEY, "providerId" text NOT NULL, "accountId" text NOT NULL, "userId" text NOT NULL, password text, issuer text NOT NULL)');
|
|
30
|
+
database.run('CREATE UNIQUE INDEX "account_issuer_accountId_uidx" ON account (issuer, "accountId")');
|
|
31
|
+
database.run('INSERT INTO account VALUES (\'account\', \'credential\', \'user\', \'user\', \'password-hash\', \'local:credential\')');
|
|
32
|
+
return database;
|
|
33
|
+
}
|
|
83
34
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
90
|
-
expect(sql).not.toContain("WHEN 'microsoft'");
|
|
35
|
+
describe("Better Auth account identity cleanup", () => {
|
|
36
|
+
it("leaves an absent PostgreSQL table to the auth migrator", async (): Promise<void> => {
|
|
37
|
+
const client = new AccountClient(false);
|
|
38
|
+
expect(await migrate(client)).toBe("absent");
|
|
39
|
+
expect(client.statements.at(-1)).toBe("COMMIT");
|
|
40
|
+
expect(client.released).toBe(true);
|
|
91
41
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const statements = client.statements.map(({ sql }) => sql.trim());
|
|
99
|
-
expect(statements[0]).toBe("BEGIN");
|
|
100
|
-
expect(statements.some((sql) => sql.includes('ADD COLUMN "issuer" text'))).toBe(true);
|
|
101
|
-
expect(
|
|
102
|
-
client.statements.some(
|
|
103
|
-
({ sql, values }) => sql.includes('UPDATE "account"') && values?.[0] === "local:credential",
|
|
104
|
-
),
|
|
105
|
-
).toBe(true);
|
|
106
|
-
expect(
|
|
107
|
-
client.statements.some(
|
|
108
|
-
({ sql, values }) => sql.includes('UPDATE "account"') && values?.[0] === "https://accounts.google.com",
|
|
109
|
-
),
|
|
110
|
-
).toBe(true);
|
|
111
|
-
expect(statements.some((sql) => sql.includes("CREATE TRIGGER podokit_fill_legacy_account_issuer"))).toBe(
|
|
112
|
-
true,
|
|
113
|
-
);
|
|
114
|
-
expect(statements.some((sql) => sql.includes('ALTER COLUMN "issuer" SET NOT NULL'))).toBe(true);
|
|
115
|
-
expect(statements.some((sql) => sql.includes('CREATE UNIQUE INDEX IF NOT EXISTS'))).toBe(true);
|
|
116
|
-
expect(statements.at(-1)).toBe("COMMIT");
|
|
42
|
+
it("relaxes legacy PostgreSQL constraints without rewriting account data", async (): Promise<void> => {
|
|
43
|
+
const client = new AccountClient();
|
|
44
|
+
expect(await migrate(client)).toBe("migrated");
|
|
45
|
+
expect(client.statements.some((sql) => sql.includes('ALTER COLUMN "issuer" DROP NOT NULL'))).toBe(true);
|
|
46
|
+
expect(client.statements.some((sql) => sql.startsWith("UPDATE") || sql.startsWith("DELETE"))).toBe(false);
|
|
47
|
+
expect(client.statements.at(-1)).toBe("COMMIT");
|
|
117
48
|
expect(client.released).toBe(true);
|
|
118
49
|
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
expect(client.statements.
|
|
128
|
-
expect(client.statements.some((
|
|
50
|
+
it("does not add issuer to a current PostgreSQL schema", async (): Promise<void> => {
|
|
51
|
+
const client = new AccountClient(true, false);
|
|
52
|
+
expect(await migrate(client)).toBe("current");
|
|
53
|
+
expect(client.statements.some((sql) => sql.includes("ADD COLUMN") || sql.includes("ALTER COLUMN"))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it("rolls back PostgreSQL identity collisions before changing constraints", async (): Promise<void> => {
|
|
56
|
+
const client = new AccountClient(true, true, 1);
|
|
57
|
+
await expect(migrate(client)).rejects.toThrow("duplicate providerId and accountId");
|
|
58
|
+
expect(client.statements.at(-1)).toBe("ROLLBACK");
|
|
59
|
+
expect(client.statements.some((sql) => sql.startsWith("ALTER") || sql.startsWith("DROP"))).toBe(false);
|
|
129
60
|
expect(client.released).toBe(true);
|
|
130
61
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
column: { dataType: "text", isNullable: "NO" },
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
await expect(migrateLegacyAccountIssuers(database(absent))).resolves.toBe("absent");
|
|
139
|
-
await expect(migrateLegacyAccountIssuers(database(current))).resolves.toBe("current");
|
|
140
|
-
|
|
141
|
-
expect(absent.statements.some(({ sql }) => sql.includes("CREATE TRIGGER"))).toBe(false);
|
|
142
|
-
expect(current.statements.some(({ sql }) => sql.includes("CREATE TRIGGER"))).toBe(true);
|
|
143
|
-
for (const client of [absent, current]) {
|
|
144
|
-
expect(client.statements.at(-1)?.sql.trim()).toBe("COMMIT");
|
|
145
|
-
expect(client.released).toBe(true);
|
|
146
|
-
}
|
|
62
|
+
it("leaves an absent SQLite table to the auth migrator", (): void => {
|
|
63
|
+
const database = new Database(":memory:");
|
|
64
|
+
try { expect(migrateSqliteLegacyAccountIssuers(database)).toBe("absent"); }
|
|
65
|
+
finally { database.close(); }
|
|
147
66
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
expect(
|
|
166
|
-
expect(
|
|
167
|
-
|
|
168
|
-
}
|
|
67
|
+
it("preserves SQLite accounts, accepts new rows without issuer, and can rerun", (): void => {
|
|
68
|
+
const database = legacyDatabase();
|
|
69
|
+
try {
|
|
70
|
+
expect(migrateSqliteLegacyAccountIssuers(database)).toBe("migrated");
|
|
71
|
+
expect(database.query('SELECT "userId", password FROM account').get()).toEqual({ userId: "user", password: "password-hash" });
|
|
72
|
+
database.run('INSERT INTO account (id, "providerId", "accountId", "userId") VALUES (\'new\', \'github\', \'user\', \'user\')');
|
|
73
|
+
expect(migrateSqliteLegacyAccountIssuers(database)).toBe("current");
|
|
74
|
+
expect(database.query('SELECT count(*) AS count FROM account').get()).toEqual({ count: 2 });
|
|
75
|
+
expect(() => database.run('INSERT INTO account (id, "providerId", "accountId", "userId") VALUES (\'duplicate\', \'github\', \'user\', \'other\')')).toThrow();
|
|
76
|
+
} finally { database.close(); }
|
|
77
|
+
});
|
|
78
|
+
it("rolls back SQLite collisions without losing rows, issuer, or its index", (): void => {
|
|
79
|
+
const database = legacyDatabase();
|
|
80
|
+
try {
|
|
81
|
+
database.run('INSERT INTO account VALUES (\'collision\', \'credential\', \'user\', \'other\', \'other-hash\', \'other-issuer\')');
|
|
82
|
+
expect(() => migrateSqliteLegacyAccountIssuers(database)).toThrow("duplicate providerId and accountId");
|
|
83
|
+
expect(database.query('SELECT count(*) AS count FROM account').get()).toEqual({ count: 2 });
|
|
84
|
+
expect(database.query('SELECT issuer FROM account WHERE id = \'account\'').get()).toEqual({ issuer: "local:credential" });
|
|
85
|
+
expect(database.query('SELECT name FROM sqlite_master WHERE name = \'account_issuer_accountId_uidx\'').get()).toBeTruthy();
|
|
86
|
+
} finally { database.close(); }
|
|
169
87
|
});
|
|
170
88
|
});
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
155
|
-
SELECT
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
12
|
+
"better-auth": "^1.7.7",
|
|
13
13
|
"pg": "^8.12.0",
|
|
14
|
-
"@better-auth/api-key": "^1.7.
|
|
15
|
-
"@better-auth/passkey": "^1.7.
|
|
16
|
-
"@better-auth/oauth-provider": "^1.7.
|
|
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": [
|
|
@@ -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 +
|
|
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
|
|
14
|
-
- `.podokit/` —
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
{{
|
|
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
|
|
82
|
-
|
|
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 {
|
|
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];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("Todo CRUD preserves UUIDs, booleans, and timestamps @smoke", async ({ request, baseURL }): Promise<void> => {
|
|
4
|
+
const title = `Todo API ${crypto.randomUUID()}`;
|
|
5
|
+
const headers = { Origin: baseURL ?? "http://localhost:5001" };
|
|
6
|
+
const response = await request.post("/api/todos", { data: { title }, headers });
|
|
7
|
+
expect(response.status()).toBe(201);
|
|
8
|
+
const created: unknown = await response.json();
|
|
9
|
+
expect(created).toMatchObject({ title, completed: false, id: expect.any(String), createdAt: expect.any(String) });
|
|
10
|
+
if (typeof created !== "object" || created === null || !("id" in created) || typeof created.id !== "string"
|
|
11
|
+
|| !("createdAt" in created) || typeof created.createdAt !== "string") {
|
|
12
|
+
throw new Error("Todo response must include an ID and timestamp");
|
|
13
|
+
}
|
|
14
|
+
const id = created.id;
|
|
15
|
+
try {
|
|
16
|
+
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
17
|
+
expect(new Date(created.createdAt).toISOString()).toBe(created.createdAt);
|
|
18
|
+
const list = await request.get("/api/todos");
|
|
19
|
+
expect(list.status()).toBe(200);
|
|
20
|
+
expect(await list.json()).toEqual(expect.arrayContaining([created]));
|
|
21
|
+
|
|
22
|
+
const update = await request.patch(`/api/todos/${id}`, { data: { title: `${title} updated`, completed: true }, headers });
|
|
23
|
+
expect(update.status()).toBe(200);
|
|
24
|
+
expect(await update.json()).toEqual({ ...created, title: `${title} updated`, completed: true });
|
|
25
|
+
expect((await request.delete(`/api/todos/${id}`, { headers })).status()).toBe(204);
|
|
26
|
+
expect((await request.get(`/api/todos/${id}`)).status()).toBe(404);
|
|
27
|
+
expect((await request.patch(`/api/todos/${id}`, { data: { completed: false }, headers })).status()).toBe(404);
|
|
28
|
+
expect((await request.delete(`/api/todos/${id}`, { headers })).status()).toBe(404);
|
|
29
|
+
} finally {
|
|
30
|
+
await request.delete(`/api/todos/${id}`, { headers });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("Todo items persist through create, complete, reload, and delete @smoke", async ({ page, request, baseURL }): Promise<void> => {
|
|
4
|
+
const title = `Todo UI ${crypto.randomUUID()}`;
|
|
5
|
+
try {
|
|
6
|
+
await page.goto("/");
|
|
7
|
+
await page.getByPlaceholder("Add a todo…").fill(title);
|
|
8
|
+
await page.getByRole("button", { name: "Add", exact: true }).click();
|
|
9
|
+
const row = page.getByRole("listitem").filter({ hasText: title });
|
|
10
|
+
await expect(row).toBeVisible();
|
|
11
|
+
await expect(row.getByRole("checkbox")).not.toBeChecked();
|
|
12
|
+
await row.getByRole("checkbox").check();
|
|
13
|
+
await expect(row.getByRole("checkbox")).toBeChecked();
|
|
14
|
+
await page.reload();
|
|
15
|
+
await expect(row.getByRole("checkbox")).toBeChecked();
|
|
16
|
+
await row.getByRole("button", { name: "Delete", exact: true }).click();
|
|
17
|
+
await expect(row).toHaveCount(0);
|
|
18
|
+
await page.reload();
|
|
19
|
+
await expect(row).toHaveCount(0);
|
|
20
|
+
} finally {
|
|
21
|
+
const items: unknown = await (await request.get("/api/todos")).json();
|
|
22
|
+
if (Array.isArray(items)) {
|
|
23
|
+
for (const item of items as unknown[]) {
|
|
24
|
+
if (typeof item === "object" && item !== null && "title" in item && item.title === title
|
|
25
|
+
&& "id" in item && typeof item.id === "string") {
|
|
26
|
+
await request.delete(`/api/todos/${item.id}`, { headers: { Origin: baseURL ?? "http://localhost:5001" } });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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": {
|