@podosoft/podokit 1.0.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.
Files changed (119) hide show
  1. package/README.md +74 -6
  2. package/dist/add.d.ts +14 -1
  3. package/dist/add.js +42 -5
  4. package/dist/create.d.ts +4 -0
  5. package/dist/create.js +9 -0
  6. package/dist/deploy-compose-profile.d.ts +3 -0
  7. package/dist/deploy-compose-profile.js +74 -8
  8. package/dist/deploy-compose-render.d.ts +1 -0
  9. package/dist/deploy-compose-render.js +46 -1
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy.js +157 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +41 -0
  15. package/dist/lib.d.ts +2 -0
  16. package/dist/lib.js +8 -2
  17. package/dist/lockfile.d.ts +6 -1
  18. package/dist/lockfile.js +22 -4
  19. package/dist/provider.d.ts +24 -0
  20. package/dist/provider.js +142 -0
  21. package/dist/remove.js +6 -3
  22. package/dist/templates/base/AGENTS.md +9 -3
  23. package/dist/templates/base/README.md +12 -8
  24. package/dist/templates/fullstack/AGENTS.md +24 -5
  25. package/dist/templates/fullstack/README.md +75 -11
  26. package/dist/templates/fullstack/apps/api/package.json +4 -3
  27. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  28. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  29. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  30. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  31. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  32. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  33. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  35. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  36. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  37. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  38. package/dist/templates/fullstack/dot-env.example +2 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  48. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  49. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  58. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
  59. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  60. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  61. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  62. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  63. package/dist/templates/modules/auth/module.manifest.json +6 -6
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  65. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  67. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  68. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  69. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  70. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  71. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  73. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  75. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  76. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  77. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  78. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  79. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  81. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  83. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  85. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  87. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  88. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  89. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  90. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  91. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  92. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  93. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  94. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  95. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  96. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  97. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  98. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  99. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  100. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  101. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  102. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  103. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  104. package/dist/templates/modules/redis/module.manifest.json +2 -0
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  106. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  108. package/dist/templates/modules/sse/module.manifest.json +4 -6
  109. package/dist/templates/todo/AGENTS.md +25 -6
  110. package/dist/templates/todo/README.md +72 -14
  111. package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
  112. package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
  113. package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
  114. package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
  115. package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
  116. package/package.json +2 -1
  117. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  118. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  119. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -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. Add the `fullstack` template when you
6
- need an Elysia API and SvelteKit web application.
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.
@@ -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.
13
+ - `infra/` — provider-aware Docker Compose and example k3s manifests.
14
14
  - `.podokit/` — PodoKit generation metadata plus application-owned development and
15
15
  deployment profiles (see "PodoKit tooling" below).
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,
@@ -74,9 +92,10 @@ Useful commands:
74
92
  exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
75
93
  - `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.
76
94
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
95
+ - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
77
96
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
78
97
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
79
- - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
98
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
80
99
  - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
81
100
 
82
101
  ## Commit conventions
@@ -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 + PostgreSQL. TypeORM is used only to execute migrations.
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 (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
 
@@ -40,7 +78,7 @@ Run lifecycle and container commands from a second terminal:
40
78
  ```
41
79
 
42
80
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
43
- `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
81
+ `storage`, and `queue` when installed modules require Redis, Silo, or a worker.
44
82
  `dev up` uses the same shared gateway and module profiles without keeping Compose
45
83
  Watch attached.
46
84
  You can still activate an additional Compose profile explicitly:
@@ -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 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:
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
- # start local PostgreSQL + Redis
64
- docker compose -f infra/docker/docker-compose.yml up -d
114
+ {{apiRun}} dev # terminal 1
115
+ ```
65
116
 
66
- {{packageManager}} run dev
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 for request-time queries and TypeORM only as a migration
75
- runner. Add a numbered migration under `apps/api/src/migrations`, then run it:
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
 
@@ -118,5 +175,12 @@ docker build --build-arg PODOKIT_BUILD_ORIGIN=https://example.com -f apps/web/Do
118
175
 
119
176
  For a Docker Compose deployment behind a trusted reverse proxy, set
120
177
  `PROTOCOL_HEADER=x-forwarded-proto` and `HOST_HEADER=x-forwarded-host` in the
121
- deployment profile runtime configuration. Never trust client-controlled forwarded
122
- headers on a directly exposed container.
178
+ deployment profile runtime configuration and list the proxy's direct CIDR blocks in
179
+ `exposure.trustedProxyCidrs`. Never trust client-controlled forwarded headers on a
180
+ directly exposed container.
181
+
182
+ If the production API exposes WebSocket endpoints, list their exact paths under
183
+ `exposure.webSocketPaths` in the deployment profile. The Compose driver then keeps a
184
+ single published port, sends only those paths to API, and sends all other traffic to
185
+ web. The external TLS proxy continues to use the same single upstream. Keep
186
+ `trustedProxyCidrs` empty unless that upstream is a known reverse proxy.
@@ -6,17 +6,18 @@
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",
13
13
  "typeorm": "bunx typeorm-ts-node-commonjs -d src/database/data-source.ts",
14
- "migration:run": "bun run typeorm -- migration:run",
15
- "migration:revert": "bun run typeorm -- migration:revert",
14
+ "migration:run": "bun src/database/migrate.ts",
15
+ "migration:revert": "bun src/database/migrate.ts --revert",
16
16
  "migration:generate": "bun run typeorm -- migration:generate"
17
17
  },
18
18
  "dependencies": {
19
19
  "@podosoft/podokit-contracts": "^0.4.0",
20
+ "@podosoft/podokit-runtime": "^0.2.0",
20
21
  "@elysia/cors": "^1.4.2",
21
22
  "@elysia/openapi": "^1.4.15",
22
23
  "dotenv": "^16.4.5",
@@ -5,6 +5,7 @@ const schema = z.object({
5
5
  NODE_ENV: z.string().default("development"),
6
6
  PORT: z.coerce.number().default(5002),
7
7
  CORS_ORIGIN: z.string().optional(),
8
+ DATABASE_URL: z.string().optional(),
8
9
  POSTGRES_HOST: z.string().default("localhost"),
9
10
  POSTGRES_PORT: z.coerce.number().default(5432),
10
11
  POSTGRES_USER: z.string().default("podokit"),
@@ -0,0 +1,9 @@
1
+ import type { ProviderSelections } from "@podosoft/podokit-runtime";
2
+
3
+ export const PROVIDERS = {
4
+ database: "{{databaseProvider}}",
5
+ cache: "{{cacheProvider}}",
6
+ "object-storage": "{{objectStorageProvider}}",
7
+ events: "{{eventsProvider}}",
8
+ jobs: "{{jobsProvider}}",
9
+ } as const satisfies ProviderSelections;
@@ -1,10 +1,14 @@
1
1
  import { Elysia } from "elysia";
2
2
  import pino, { type Logger } from "pino";
3
+ import {
4
+ DATABASE as RUNTIME_DATABASE,
5
+ type ServiceKey,
6
+ } from "@podosoft/podokit-runtime";
3
7
  import type { AppEnv } from "../config/env.validation";
4
8
  import { Database } from "../database/database";
5
9
  import { ReadinessService } from "../health/readiness.service";
6
10
 
7
- export type ServiceKey<T> = symbol & { readonly __service?: T };
11
+ export type { ServiceKey } from "@podosoft/podokit-runtime";
8
12
 
9
13
  export class ServiceRegistry {
10
14
  private readonly values = new Map<symbol, unknown>();
@@ -142,7 +146,7 @@ export class OpenApiRegistry {
142
146
  }
143
147
  }
144
148
 
145
- export const DATABASE = Symbol("database") as ServiceKey<Database>;
149
+ export const DATABASE = RUNTIME_DATABASE as ServiceKey<Database>;
146
150
  export const READINESS = Symbol("readiness") as ServiceKey<ReadinessService>;
147
151
  export const LOGGER = Symbol("logger") as ServiceKey<Logger>;
148
152
  export const ACCESS_POLICY = Symbol("access-policy") as ServiceKey<AccessPolicy>;
@@ -185,6 +189,7 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
185
189
  accessPolicy.register("*", "/api-docs-json", "public");
186
190
  accessPolicy.register("*", "/api-docs-elysia-json", "public");
187
191
  services.register(DATABASE, database, () => database.close());
192
+ services.onStart(() => database.connect());
188
193
  services.register(READINESS, readiness);
189
194
  services.register(LOGGER, logger);
190
195
  services.register(ACCESS_POLICY, accessPolicy);
@@ -2,23 +2,45 @@ import "dotenv/config";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { DataSource, type DataSourceOptions } from "typeorm";
5
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
+ import { PROVIDERS } from "../config/providers";
7
+ import { validateEnv } from "../config/env.validation";
8
+ import { databaseUrl, sqliteDatabasePath } from "./database";
5
9
 
6
10
  const compiledMigrations = join(process.cwd(), "dist", "migrations");
7
11
  const migrations = existsSync(compiledMigrations)
8
12
  ? [join(compiledMigrations, "[0-9]*.js")]
9
13
  : [join(process.cwd(), "src", "migrations", "[0-9]*.ts")];
10
14
 
11
- export const dataSourceOptions: DataSourceOptions = {
12
- type: "postgres",
13
- host: process.env.POSTGRES_HOST ?? "localhost",
14
- port: Number(process.env.POSTGRES_PORT ?? 5432),
15
- username: process.env.POSTGRES_USER ?? "podokit",
16
- password: process.env.POSTGRES_PASSWORD ?? "podokit",
17
- database: process.env.POSTGRES_DB ?? "podokit",
15
+ const commonOptions = {
18
16
  entities: [],
19
17
  migrations,
20
18
  synchronize: false,
21
19
  };
22
20
 
21
+ const env = validateEnv(process.env);
22
+ function databaseProvider(): DatabaseProviderName {
23
+ return PROVIDERS.database;
24
+ }
25
+
26
+ export const dataSourceOptions: DataSourceOptions = databaseProvider() === "sqlite"
27
+ ? {
28
+ type: "better-sqlite3",
29
+ database: sqliteDatabasePath(databaseUrl(env)),
30
+ enableWAL: true,
31
+ ...commonOptions,
32
+ }
33
+ : {
34
+ type: "postgres",
35
+ ...(process.env.DATABASE_URL ? { url: process.env.DATABASE_URL } : {}),
36
+ host: env.POSTGRES_HOST,
37
+ port: env.POSTGRES_PORT,
38
+ username: env.POSTGRES_USER,
39
+ password: env.POSTGRES_PASSWORD,
40
+ database: env.POSTGRES_DB,
41
+ ...commonOptions,
42
+ };
43
+
23
44
  // Used by the TypeORM CLI for migrations (see package.json scripts).
24
- export default new DataSource(dataSourceOptions);
45
+ export const appDataSource = new DataSource(dataSourceOptions);
46
+ export default appDataSource;
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import type { AppEnv } from "../config/env.validation";
6
+ import { PROVIDERS } from "../config/providers";
7
+ import { databaseUrl, sqliteDatabasePath } from "./database";
8
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
9
+
10
+ const env: AppEnv = {
11
+ NODE_ENV: "test",
12
+ PORT: 5002,
13
+ POSTGRES_HOST: "localhost",
14
+ POSTGRES_PORT: 5432,
15
+ POSTGRES_USER: "podokit",
16
+ POSTGRES_PASSWORD: "podokit",
17
+ POSTGRES_DB: "podokit",
18
+ };
19
+
20
+ function databaseProvider(): DatabaseProviderName {
21
+ return PROVIDERS.database;
22
+ }
23
+
24
+ describe("database provider", () => {
25
+ it("uses DATABASE_URL as the canonical connection setting", () => {
26
+ const provider = databaseProvider();
27
+ const DATABASE_URL = provider === "sqlite"
28
+ ? "sqlite://:memory:"
29
+ : "postgres://podokit:podokit@localhost:5432/podokit";
30
+ expect(databaseUrl({ ...env, DATABASE_URL })).toBe(DATABASE_URL);
31
+ });
32
+
33
+ it("keeps the PostgreSQL fields as a fallback and creates a SQLite default", () => {
34
+ const url = databaseUrl(env);
35
+ const provider = databaseProvider();
36
+ if (provider === "sqlite") expect(url).toMatch(/^sqlite:\/\//);
37
+ else expect(url).toBe("postgres://podokit:podokit@localhost:5432/podokit");
38
+ });
39
+
40
+ it("rejects a connection URL for the other database dialect", () => {
41
+ const provider = databaseProvider();
42
+ const DATABASE_URL = provider === "sqlite"
43
+ ? "postgres://podokit:podokit@localhost:5432/podokit"
44
+ : "sqlite://:memory:";
45
+ expect(() => databaseUrl({ ...env, DATABASE_URL })).toThrow("DATABASE_URL must use");
46
+ });
47
+
48
+ it("creates the parent directory for an explicit SQLite database", () => {
49
+ const root = mkdtempSync(join(tmpdir(), "podokit-sqlite-"));
50
+ try {
51
+ const path = join(root, "nested", "app.sqlite");
52
+ expect(sqliteDatabasePath(`sqlite://${path}`)).toBe(path);
53
+ expect(existsSync(dirname(path))).toBe(true);
54
+ } finally {
55
+ rmSync(root, { recursive: true, force: true });
56
+ }
57
+ });
58
+ });
@@ -1,22 +1,78 @@
1
1
  import { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
2
5
  import type { AppEnv } from "../config/env.validation";
6
+ import { PROVIDERS } from "../config/providers";
7
+
8
+ function isSqliteUrl(url: string): boolean {
9
+ return url === ":memory:" || /^(?:sqlite|file):/.test(url);
10
+ }
11
+
12
+ function selectedDatabaseProvider(): DatabaseProviderName {
13
+ return PROVIDERS.database;
14
+ }
3
15
 
4
16
  export function databaseUrl(env: AppEnv): string {
17
+ const provider = selectedDatabaseProvider();
18
+ if (env.DATABASE_URL) {
19
+ if (provider === "sqlite" && !isSqliteUrl(env.DATABASE_URL)) {
20
+ throw new Error("DATABASE_URL must use sqlite:, file:, or :memory: for the SQLite provider");
21
+ }
22
+ if (provider === "postgres" && isSqliteUrl(env.DATABASE_URL)) {
23
+ throw new Error("DATABASE_URL must use postgres: for the PostgreSQL provider");
24
+ }
25
+ return env.DATABASE_URL;
26
+ }
27
+ if (provider === "sqlite") {
28
+ const path = resolve("./data/podokit.sqlite");
29
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
30
+ return `sqlite://${path}`;
31
+ }
5
32
  const username = encodeURIComponent(env.POSTGRES_USER);
6
33
  const password = encodeURIComponent(env.POSTGRES_PASSWORD);
7
34
  const database = encodeURIComponent(env.POSTGRES_DB);
8
35
  return `postgres://${username}:${password}@${env.POSTGRES_HOST}:${env.POSTGRES_PORT}/${database}`;
9
36
  }
10
37
 
38
+ export function sqliteDatabasePath(url: string): string {
39
+ if (url === ":memory:" || url === "sqlite://:memory:") return ":memory:";
40
+ const path = url.startsWith("sqlite://")
41
+ ? url.slice("sqlite://".length)
42
+ : url.startsWith("file:")
43
+ ? decodeURIComponent(new URL(url).pathname)
44
+ : undefined;
45
+ if (!path) throw new Error("SQLite database URL must use sqlite:, file:, or :memory:");
46
+ mkdirSync(dirname(resolve(path)), { recursive: true, mode: 0o700 });
47
+ return path;
48
+ }
49
+
11
50
  export class Database {
51
+ readonly provider: DatabaseProviderName = selectedDatabaseProvider();
12
52
  readonly sql: SQL;
53
+ private ready?: Promise<void>;
13
54
 
14
55
  constructor(env: AppEnv) {
15
- this.sql = new SQL(databaseUrl(env), { max: 20 });
56
+ const url = databaseUrl(env);
57
+ this.sql = this.provider === "sqlite"
58
+ ? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
59
+ : new SQL(url, { max: 20 });
60
+ }
61
+
62
+ connect(): Promise<void> {
63
+ this.ready ??= (async () => {
64
+ if (this.provider === "sqlite") {
65
+ await this.sql`PRAGMA journal_mode = WAL`;
66
+ await this.sql`PRAGMA foreign_keys = ON`;
67
+ await this.sql`PRAGMA busy_timeout = 5000`;
68
+ }
69
+ await this.sql`SELECT 1`;
70
+ })();
71
+ return this.ready;
16
72
  }
17
73
 
18
74
  async ping(): Promise<void> {
19
- await this.sql`SELECT 1`;
75
+ await this.connect();
20
76
  }
21
77
 
22
78
  async close(): Promise<void> {
@@ -1,9 +1,31 @@
1
- import dataSource from "./data-source";
1
+ import { Database as SqliteDatabase } from "bun:sqlite";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { PROVIDERS } from "../config/providers";
5
+ import { databaseUrl, sqliteDatabasePath } from "./database";
6
+ import { revertLatestSqliteMigration, runSqliteMigrations } from "./sqlite-migrator";
7
+
8
+ function databaseProvider(): DatabaseProviderName {
9
+ return PROVIDERS.database;
10
+ }
2
11
 
3
12
  async function migrate(): Promise<void> {
13
+ if (databaseProvider() === "sqlite") {
14
+ const env = validateEnv(process.env);
15
+ const database = new SqliteDatabase(sqliteDatabasePath(databaseUrl(env)), { create: true });
16
+ try {
17
+ if (process.argv.includes("--revert")) await revertLatestSqliteMigration(database);
18
+ else await runSqliteMigrations(database);
19
+ } finally {
20
+ database.close();
21
+ }
22
+ return;
23
+ }
24
+ const { appDataSource: dataSource } = await import("./data-source.js");
4
25
  await dataSource.initialize();
5
26
  try {
6
- await dataSource.runMigrations({ transaction: "all" });
27
+ if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
28
+ else await dataSource.runMigrations({ transaction: "all" });
7
29
  } finally {
8
30
  await dataSource.destroy();
9
31
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Database as SqliteDatabase } from "bun:sqlite";
3
+ import type { MigrationInterface, QueryRunner } from "typeorm";
4
+ import {
5
+ revertLatestSqliteMigration,
6
+ runSqliteMigrations,
7
+ type SqliteMigrationDefinition,
8
+ } from "./sqlite-migrator";
9
+
10
+ class CreateExample1000 implements MigrationInterface {
11
+ name = "CreateExample1000";
12
+
13
+ async up(queryRunner: QueryRunner): Promise<void> {
14
+ await queryRunner.query('CREATE TABLE "example" ("id" text PRIMARY KEY)');
15
+ }
16
+
17
+ async down(queryRunner: QueryRunner): Promise<void> {
18
+ await queryRunner.query('DROP TABLE "example"');
19
+ }
20
+ }
21
+
22
+ const definitions: readonly SqliteMigrationDefinition[] = [
23
+ { timestamp: 1_000, migration: new CreateExample1000() },
24
+ ];
25
+
26
+ describe("SQLite migrator", () => {
27
+ it("runs each migration once and records it atomically", async () => {
28
+ const database = new SqliteDatabase(":memory:");
29
+ try {
30
+ await runSqliteMigrations(database, definitions);
31
+ await runSqliteMigrations(database, definitions);
32
+ expect(database.query<{ count: number }, []>('SELECT count(*) AS "count" FROM "migrations"').get()?.count).toBe(1);
33
+ expect(database.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE name = 'example'").get()?.name).toBe("example");
34
+ } finally {
35
+ database.close();
36
+ }
37
+ });
38
+
39
+ it("reverts the latest applied migration", async () => {
40
+ const database = new SqliteDatabase(":memory:");
41
+ try {
42
+ await runSqliteMigrations(database, definitions);
43
+ await expect(revertLatestSqliteMigration(database, definitions)).resolves.toBe(true);
44
+ expect(database.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE name = 'example'").get()).toBeNull();
45
+ await expect(revertLatestSqliteMigration(database, definitions)).resolves.toBe(false);
46
+ } finally {
47
+ database.close();
48
+ }
49
+ });
50
+ });