@podosoft/podokit 1.0.1 → 1.1.2

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 (118) hide show
  1. package/README.md +19 -5
  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 +2 -1
  9. package/dist/deploy-compose-render.js +50 -5
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.js +18 -8
  12. package/dist/deploy-render.js +5 -5
  13. package/dist/deploy.js +157 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +41 -0
  16. package/dist/lib.d.ts +2 -0
  17. package/dist/lib.js +8 -2
  18. package/dist/lockfile.d.ts +6 -1
  19. package/dist/lockfile.js +22 -4
  20. package/dist/provider.d.ts +24 -0
  21. package/dist/provider.js +142 -0
  22. package/dist/remove.js +6 -3
  23. package/dist/templates/fullstack/AGENTS.md +5 -4
  24. package/dist/templates/fullstack/README.md +10 -3
  25. package/dist/templates/fullstack/apps/api/package.json +3 -2
  26. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  27. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  28. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  29. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  30. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  31. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  32. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  34. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  35. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  36. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  37. package/dist/templates/fullstack/dot-env.example +2 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +1 -1
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +1 -1
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +10 -2
  45. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  49. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  50. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  51. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  56. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  57. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  58. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  59. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  60. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  61. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  62. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  63. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  64. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  65. package/dist/templates/modules/auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  67. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  68. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  69. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  70. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  71. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  72. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  73. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  74. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  75. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  76. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  77. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  78. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  79. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  80. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  81. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  82. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  83. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  84. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  85. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  86. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  87. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  88. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  89. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  90. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  91. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  92. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  93. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  94. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  95. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  96. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  97. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  98. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  99. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  100. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  101. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  102. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  103. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  104. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  105. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  106. package/dist/templates/modules/redis/module.manifest.json +2 -0
  107. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  108. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  109. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  110. package/dist/templates/modules/sse/module.manifest.json +4 -6
  111. package/dist/templates/todo/AGENTS.md +1 -1
  112. package/dist/templates/todo/README.md +8 -3
  113. package/dist/toolchain.d.ts +1 -0
  114. package/dist/toolchain.js +4 -0
  115. package/package.json +2 -1
  116. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  117. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  118. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
package/dist/remove.js CHANGED
@@ -6,7 +6,6 @@ const node_path_1 = require("node:path");
6
6
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
7
7
  const add_1 = require("./add");
8
8
  const lockfile_1 = require("./lockfile");
9
- const toolchain_1 = require("./toolchain");
10
9
  function readModuleManifest(moduleDir, vars) {
11
10
  const source = (0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8");
12
11
  return JSON.parse(vars ? (0, podokit_template_engine_1.renderTokens)(source, vars) : source);
@@ -51,7 +50,7 @@ function removeModule(options) {
51
50
  }
52
51
  const vars = {
53
52
  projectName: projectName(projectRoot),
54
- ...(0, toolchain_1.toolchainTemplateVars)(manifest.toolchain),
53
+ ...(0, lockfile_1.manifestTemplateVars)(manifest),
55
54
  };
56
55
  const targetManifest = readModuleManifest(moduleDir, vars);
57
56
  // Resolve the manifests of the OTHER installed modules once — used for the
@@ -64,7 +63,11 @@ function removeModule(options) {
64
63
  })
65
64
  .filter((m) => m !== null);
66
65
  // Guard: refuse if another installed module requires this one.
67
- const dependents = others.filter((o) => (o.manifest.requires ?? []).includes(module)).map((o) => o.name);
66
+ const selectedCapabilities = Object.entries(targetManifest.provides ?? {})
67
+ .filter(([capability, provider]) => manifest.providers[capability] === provider)
68
+ .map(([capability]) => capability);
69
+ const dependents = others.filter((other) => (other.manifest.requires ?? []).includes(module)
70
+ || (other.manifest.requiresCapabilities ?? []).some((capability) => selectedCapabilities.includes(capability))).map((other) => other.name);
68
71
  if (dependents.length) {
69
72
  throw new Error(`Cannot remove "${module}": required by ${dependents.join(", ")}. Remove ${dependents.length > 1 ? "those" : "that"} first.`);
70
73
  }
@@ -8,9 +8,9 @@ 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
 
@@ -19,7 +19,7 @@ A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
19
19
  ```bash
20
20
  {{packageManager}} install
21
21
  cp .env.example .env
22
- docker compose -f infra/docker/docker-compose.yml up -d # PostgreSQL (+ Redis)
22
+ docker compose -f infra/docker/docker-compose.yml up -d # selected external providers only
23
23
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
24
24
  ```
25
25
 
@@ -74,9 +74,10 @@ Useful commands:
74
74
  exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
75
75
  - `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
76
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
77
+ - `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
78
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
78
79
  - `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`.
80
+ - `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
81
  - `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
82
 
82
83
  ## Commit conventions
@@ -40,7 +40,7 @@ Run lifecycle and container commands from a second terminal:
40
40
  ```
41
41
 
42
42
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
43
- `storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
43
+ `storage`, and `queue` when installed modules require Redis, Silo, or a worker.
44
44
  `dev up` uses the same shared gateway and module profiles without keeping Compose
45
45
  Watch attached.
46
46
  You can still activate an additional Compose profile explicitly:
@@ -118,5 +118,12 @@ docker build --build-arg PODOKIT_BUILD_ORIGIN=https://example.com -f apps/web/Do
118
118
 
119
119
  For a Docker Compose deployment behind a trusted reverse proxy, set
120
120
  `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.
121
+ deployment profile runtime configuration and list the proxy's direct CIDR blocks in
122
+ `exposure.trustedProxyCidrs`. Never trust client-controlled forwarded headers on a
123
+ directly exposed container.
124
+
125
+ If the production API exposes WebSocket endpoints, list their exact paths under
126
+ `exposure.webSocketPaths` in the deployment profile. The Compose driver then keeps a
127
+ single published port, sends only those paths to API, and sends all other traffic to
128
+ web. The external TLS proxy continues to use the same single upstream. Keep
129
+ `trustedProxyCidrs` empty unless that upstream is a known reverse proxy.
@@ -11,12 +11,13 @@
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
+ });
@@ -0,0 +1,134 @@
1
+ import { Database as SqliteDatabase } from "bun:sqlite";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { MigrationInterface, QueryRunner } from "typeorm";
6
+
7
+ export interface SqliteMigrationDefinition {
8
+ timestamp: number;
9
+ migration: MigrationInterface;
10
+ }
11
+
12
+ interface AppliedMigration {
13
+ id: number;
14
+ timestamp: number;
15
+ name: string;
16
+ }
17
+
18
+ type MigrationConstructor = new () => MigrationInterface;
19
+
20
+ function isMigration(value: unknown): value is MigrationConstructor {
21
+ if (typeof value !== "function") return false;
22
+ const prototype = (value as { prototype?: unknown }).prototype;
23
+ if (!prototype || typeof prototype !== "object") return false;
24
+ const record = prototype as Record<string, unknown>;
25
+ return typeof record.up === "function" && typeof record.down === "function";
26
+ }
27
+
28
+ async function discoverMigrations(): Promise<SqliteMigrationDefinition[]> {
29
+ const compiled = join(process.cwd(), "dist", "migrations");
30
+ const source = join(process.cwd(), "src", "migrations");
31
+ const directory = process.env.NODE_ENV === "production" && existsSync(compiled)
32
+ ? compiled
33
+ : source;
34
+ if (!existsSync(directory)) return [];
35
+ const files = readdirSync(directory)
36
+ .filter((file) => /^\d+-.+\.(?:js|ts)$/.test(file))
37
+ .sort();
38
+ const definitions: SqliteMigrationDefinition[] = [];
39
+ for (const file of files) {
40
+ const loaded = await import(pathToFileURL(join(directory, file)).href) as Record<string, unknown>;
41
+ const constructors = Object.values(loaded).filter(isMigration);
42
+ if (constructors.length !== 1) {
43
+ throw new Error(`SQLite migration ${file} must export exactly one migration class`);
44
+ }
45
+ const timestamp = Number(file.slice(0, file.indexOf("-")));
46
+ if (!Number.isSafeInteger(timestamp)) throw new Error(`SQLite migration ${file} has an invalid timestamp`);
47
+ const Constructor = constructors[0];
48
+ if (!Constructor) throw new Error(`SQLite migration ${file} has no migration class`);
49
+ definitions.push({ timestamp, migration: new Constructor() });
50
+ }
51
+ return definitions;
52
+ }
53
+
54
+ function queryRunner(database: SqliteDatabase): QueryRunner {
55
+ return {
56
+ connection: { options: { type: "better-sqlite3" } },
57
+ query: (sql: string): Promise<unknown> => Promise.resolve(database.run(sql)),
58
+ } as unknown as QueryRunner;
59
+ }
60
+
61
+ function prepare(database: SqliteDatabase): void {
62
+ database.run("PRAGMA journal_mode = WAL");
63
+ database.run("PRAGMA foreign_keys = ON");
64
+ database.run("PRAGMA busy_timeout = 5000");
65
+ database.run(`
66
+ CREATE TABLE IF NOT EXISTS "migrations" (
67
+ "id" integer PRIMARY KEY AUTOINCREMENT,
68
+ "timestamp" bigint NOT NULL,
69
+ "name" text NOT NULL UNIQUE
70
+ )
71
+ `);
72
+ }
73
+
74
+ function migrationName(migration: MigrationInterface): string {
75
+ if (typeof migration.name !== "string" || migration.name.length === 0) {
76
+ throw new Error("SQLite migration classes must declare a non-empty name");
77
+ }
78
+ return migration.name;
79
+ }
80
+
81
+ export async function runSqliteMigrations(
82
+ database: SqliteDatabase,
83
+ supplied?: readonly SqliteMigrationDefinition[],
84
+ ): Promise<void> {
85
+ prepare(database);
86
+ const definitions = supplied ? [...supplied] : await discoverMigrations();
87
+ definitions.sort((left, right) => left.timestamp - right.timestamp);
88
+ const applied = new Set(
89
+ database.query<Pick<AppliedMigration, "name">, []>(
90
+ 'SELECT "name" FROM "migrations"',
91
+ ).all().map((row) => row.name),
92
+ );
93
+ const runner = queryRunner(database);
94
+ for (const definition of definitions) {
95
+ const name = migrationName(definition.migration);
96
+ if (applied.has(name)) continue;
97
+ database.run("BEGIN IMMEDIATE");
98
+ try {
99
+ await definition.migration.up(runner);
100
+ database.query(
101
+ 'INSERT INTO "migrations" ("timestamp", "name") VALUES (?1, ?2)',
102
+ ).run(definition.timestamp, name);
103
+ database.run("COMMIT");
104
+ } catch (error) {
105
+ database.run("ROLLBACK");
106
+ throw error;
107
+ }
108
+ }
109
+ }
110
+
111
+ export async function revertLatestSqliteMigration(
112
+ database: SqliteDatabase,
113
+ supplied?: readonly SqliteMigrationDefinition[],
114
+ ): Promise<boolean> {
115
+ prepare(database);
116
+ const latest = database.query<AppliedMigration, []>(
117
+ 'SELECT "id", "timestamp", "name" FROM "migrations" ORDER BY "id" DESC LIMIT 1',
118
+ ).get();
119
+ if (!latest) return false;
120
+ const definitions = supplied ? [...supplied] : await discoverMigrations();
121
+ const definition = definitions.find((item) => migrationName(item.migration) === latest.name);
122
+ if (!definition) throw new Error(`Cannot find SQLite migration ${latest.name} to revert`);
123
+ const runner = queryRunner(database);
124
+ database.run("BEGIN IMMEDIATE");
125
+ try {
126
+ await definition.migration.down(runner);
127
+ database.query('DELETE FROM "migrations" WHERE "id" = ?1').run(latest.id);
128
+ database.run("COMMIT");
129
+ return true;
130
+ } catch (error) {
131
+ database.run("ROLLBACK");
132
+ throw error;
133
+ }
134
+ }
@@ -102,7 +102,7 @@ services:
102
102
 
103
103
  minio:
104
104
  profiles: [storage]
105
- image: minio/minio:latest
105
+ image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z
106
106
  command: server /data --console-address ":9001"
107
107
  environment:
108
108
  MINIO_ROOT_USER: podokit
@@ -113,7 +113,7 @@ services:
113
113
 
114
114
  minio-init:
115
115
  profiles: [storage]
116
- image: minio/mc:latest
116
+ image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z
117
117
  depends_on:
118
118
  minio: { condition: service_started }
119
119
  entrypoint:
@@ -60,9 +60,14 @@ it at a different host invalidates the profile instead of deploying somewhere el
60
60
  The fields that differ from the Kubernetes driver:
61
61
 
62
62
  - `target`: Docker context, endpoint fingerprint, Compose project name
63
- - `exposure`: `bindAddress` and `port` published from the web service. It defaults to
64
- `127.0.0.1`, because the expected front door is a reverse proxy on the same host —
65
- binding every interface publishes the app before TLS exists
63
+ - `exposure`: `bindAddress` and `port`, plus an exact `webSocketPaths` allowlist,
64
+ `trustedProxyCidrs`, and `gatewayImage`. With an empty allowlist, web owns the
65
+ published port. With paths, an internal gateway owns it, sends only exact listed
66
+ paths to API, and sends every other request to web. Forwarding headers are trusted
67
+ only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
68
+ external proxy. The bind address defaults to `127.0.0.1`, because the expected
69
+ front door is a trusted reverse proxy — binding every interface publishes the app
70
+ before TLS exists.
66
71
  - `workloads`: replicas plus Compose `cpus` and `memory` limits
67
72
  - `dependencies`: `managed`, `external`, or `disabled`, each with a named Docker
68
73
  volume and the path of its env file on the target host
@@ -86,3 +91,5 @@ Required keys:
86
91
  Data volumes are declared `external`, so `docker compose down -v` cannot delete the
87
92
  database. Editing an env file changes the containers' rollout label, so the next
88
93
  apply actually restarts them instead of leaving stale environment values running.
94
+ The gateway image is resolved to an immutable digest during planning. The external
95
+ TLS proxy keeps one upstream; do not duplicate the WebSocket path split there.
@@ -4,6 +4,8 @@
4
4
  NODE_ENV=development
5
5
  PORT=5002
6
6
 
7
+ # Canonical database connection. Set this for PostgreSQL or SQLite; POSTGRES_* remains a fallback.
8
+ # DATABASE_URL=postgres://podokit:podokit@localhost:5432/podokit
7
9
  POSTGRES_HOST=localhost
8
10
  POSTGRES_PORT=5432
9
11
  POSTGRES_USER=podokit
@@ -3,6 +3,7 @@ import {
3
3
  PROFILE_IMAGE_REQUIRED,
4
4
  PROFILE_IMAGE_TOO_LARGE,
5
5
  } from "@podosoft/podokit-contracts";
6
+ import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
6
7
  import { Elysia, t } from "elysia";
7
8
  import { AUTH } from "./auth/auth.module";
8
9
  import {
@@ -20,7 +21,6 @@ import {
20
21
  SiteSettingsService,
21
22
  } from "./site-settings/site-settings.service";
22
23
  import { validateSiteSetting } from "./site-settings/site-settings.validation";
23
- import { STORAGE } from "./storage/storage.module";
24
24
 
25
25
  export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
26
26
  export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
@@ -138,7 +138,7 @@ const adminPlugin: AppPlugin = ({ services }) => {
138
138
  export const adminModule: PodokitModule = {
139
139
  name: "admin-dashboard",
140
140
  configure: (_env, services): void => {
141
- const storage = services.resolve(STORAGE);
141
+ const storage = services.resolve(OBJECT_STORAGE);
142
142
  const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
143
143
  const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
144
144
  services.register(SITE_SETTINGS, site);