@podosoft/podokit 1.0.2 → 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 (112) 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 +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/fullstack/AGENTS.md +5 -4
  23. package/dist/templates/fullstack/README.md +10 -3
  24. package/dist/templates/fullstack/apps/api/package.json +3 -2
  25. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
  26. package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
  27. package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
  28. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
  29. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
  30. package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
  31. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
  32. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
  33. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
  34. package/dist/templates/fullstack/compose.dev.yaml +2 -2
  35. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  36. package/dist/templates/fullstack/dot-env.example +2 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
  41. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
  42. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
  43. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
  45. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
  46. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
  47. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +170 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +260 -0
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
  55. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
  56. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +22 -6
  57. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
  58. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
  59. package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
  60. package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
  61. package/dist/templates/modules/auth/module.manifest.json +1 -1
  62. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
  63. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
  64. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
  65. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
  66. package/dist/templates/modules/bullmq/module.manifest.json +7 -0
  67. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
  68. package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
  69. package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
  70. package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
  71. package/dist/templates/modules/events-memory/module.manifest.json +30 -0
  72. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
  73. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
  74. package/dist/templates/modules/events-redis/module.manifest.json +32 -0
  75. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
  76. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  77. package/dist/templates/modules/file-upload/module.manifest.json +4 -3
  78. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
  79. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
  80. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
  81. package/dist/templates/modules/job-progress/module.manifest.json +7 -8
  82. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
  83. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
  84. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
  85. package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
  86. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
  87. package/dist/templates/modules/mailer/module.manifest.json +2 -4
  88. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
  89. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
  90. package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
  91. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
  92. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
  93. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
  94. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
  95. package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
  96. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
  97. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
  98. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
  99. package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
  100. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
  101. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
  102. package/dist/templates/modules/redis/module.manifest.json +2 -0
  103. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
  104. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
  105. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
  106. package/dist/templates/modules/sse/module.manifest.json +4 -6
  107. package/dist/templates/todo/AGENTS.md +1 -1
  108. package/dist/templates/todo/README.md +8 -3
  109. package/package.json +2 -1
  110. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
  111. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
  112. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
@@ -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);
@@ -1,18 +1,24 @@
1
1
  import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
2
2
  import type { Logger } from "pino";
3
+ import type { ObjectStore } from "@podosoft/podokit-runtime";
3
4
  import { runUserDeletedHandlers } from "../auth/user-delete-handlers";
4
- import { StorageService } from "../storage/storage.service";
5
5
  import { ProfileImageService } from "./profile-image.service";
6
6
 
7
7
  describe("ProfileImageService account deletion cleanup", () => {
8
8
  const storage = {
9
9
  put: mock(async () => undefined),
10
- get: mock(async () => Buffer.alloc(0)),
11
- presignedGetUrl: mock(async () => "https://example.com/image"),
10
+ get: mock(async () => ({
11
+ key: "profile-images/example.webp",
12
+ size: 0,
13
+ body: (async function* (): AsyncGenerator<Uint8Array> { yield Buffer.alloc(0); })(),
14
+ })),
15
+ exists: mock(async () => true),
16
+ getDownloadUrl: mock(async () => "https://example.com/image"),
12
17
  delete: mock(async () => undefined),
18
+ close: mock(() => undefined),
13
19
  };
14
20
  const logger = { warn: mock(() => undefined) } as unknown as Logger;
15
- const service = new ProfileImageService(storage as unknown as StorageService, logger);
21
+ const service = new ProfileImageService(storage as unknown as ObjectStore, logger);
16
22
 
17
23
  service.connect();
18
24