@zaaxch/tailframe 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/new.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { authAppStoreSource, GenerateError } from "./generate.mjs";
4
+ import { configSource } from "./config.mjs";
5
+ import { ownedSources } from "./owned-sources.mjs";
6
+ import { runSync } from "./sync.mjs";
4
7
  import {
5
8
  appErrorSource,
6
9
  applicationRoutesSource,
@@ -16,6 +19,7 @@ import {
16
19
  healthSchemasSource,
17
20
  httpErrorsSource,
18
21
  mongoReadinessProbeSource,
22
+ postgresReadinessProbeSource,
19
23
  readinessProbeSource,
20
24
  redisReadinessProbeSource,
21
25
  redisSource,
@@ -42,13 +46,14 @@ function parseArgs(argv) {
42
46
  const result = { db: "mongo", auth: "none", ui: false, redis: false, worker: false };
43
47
  for (let i = 0; i < argv.length; i += 1) {
44
48
  const arg = argv[i];
45
- if (["--name", "--path", "--auth"].includes(arg)) result[arg.slice(2)] = argv[++i];
49
+ if (["--name", "--path", "--auth", "--db"].includes(arg)) result[arg.slice(2)] = argv[++i];
46
50
  else if (["--ui", "--redis", "--worker"].includes(arg)) result[arg.slice(2)] = true;
47
51
  else fail(`Unknown argument: ${arg}`);
48
52
  }
49
53
  if (!result.name || !/^[a-z][a-z0-9-]*$/.test(result.name)) fail("--name must use lowercase letters, digits, and hyphens and start with a letter");
50
54
  if (!result.path) fail("--path is required");
51
55
  if (!["none", "firebase"].includes(result.auth)) fail("--auth must be none or firebase");
56
+ if (!["mongo", "postgres"].includes(result.db)) fail("--db must be mongo or postgres");
52
57
  if (result.worker) result.redis = true;
53
58
  return result;
54
59
  }
@@ -61,16 +66,31 @@ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) fail(`Parent d
61
66
  if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwrite non-empty directory: ${root}`);
62
67
 
63
68
  const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
64
- const svc = `${options.name}-svc`;
65
- const ui = `${options.name}-ui`;
69
+ const svc = "apps/service";
70
+ const ui = "apps/ui";
71
+ const servicePackage = `@${options.name}/service`;
72
+ const uiPackage = `@${options.name}/ui`;
66
73
  const databaseName = options.name.replaceAll("-", "_");
74
+ const postgres = options.db === "postgres";
67
75
  const mongoApplicationUser = `${options.name}-app`;
68
76
  const mongoBackupUser = `${options.name}-backup`;
69
77
  const files = {};
70
78
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
71
- // Generated repositories depend on the published toolkit rather than copying a validator, and they
79
+ // Generated products depend on the published toolkit rather than copying a validator, and they
72
80
  // pin the exact contract version they were generated against.
73
81
  const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
82
+ const serviceProfiles = [
83
+ ...(options.auth === "firebase" ? ["firebase"] : []),
84
+ options.db,
85
+ ...(options.redis ? ["redis", "rate-limit"] : []),
86
+ ...(options.worker ? ["worker"] : []),
87
+ ...(options.ui ? ["ui-host"] : [])
88
+ ];
89
+ const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
90
+ const apps = [
91
+ { kind: "service", path: svc, profiles: serviceProfiles },
92
+ ...(options.ui ? [{ kind: "ui", path: ui, profiles: uiProfiles }] : [])
93
+ ];
74
94
  const prettierConfig = {
75
95
  useTabs: true,
76
96
  tabWidth: 4,
@@ -92,92 +112,102 @@ const gitAttributes = `* text=auto eol=lf
92
112
 
93
113
  add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
94
114
  add(".gitattributes", gitAttributes);
115
+ add("package.json", JSON.stringify({
116
+ name: options.name,
117
+ version: "0.1.0",
118
+ private: true,
119
+ packageManager: "pnpm@11.22.0",
120
+ scripts: {
121
+ dev: "pnpm -r --parallel --stream --if-present dev",
122
+ build: "pnpm -r --if-present build",
123
+ test: "pnpm -r --if-present test",
124
+ "type-check": "pnpm -r --if-present type-check",
125
+ "format:check": "pnpm -r --if-present format:check",
126
+ "validate:architecture": "tailframe validate .",
127
+ "sync:architecture": "tailframe sync --check ."
128
+ },
129
+ devDependencies: { "@zaaxch/tailframe": contractVersion }
130
+ }, null, "\t"));
131
+ add("pnpm-workspace.yaml", `packages:
132
+ - "apps/*"
133
+ injectWorkspacePackages: true
134
+ allowBuilds:
135
+ "@firebase/util": true
136
+ esbuild: true
137
+ protobufjs: true
138
+ unrs-resolver: true
139
+ `);
140
+ add("tailframe.json", configSource({ apps, contractVersion }));
95
141
 
96
142
  const repos = [[svc, "Express/TypeScript API and background runtime."]];
97
143
  if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
98
144
 
99
- add("AGENTS.md", `# ${title} repository guidance
145
+ add("AGENTS.md", `# ${title} product guidance
100
146
 
101
147
  ## Sources of truth
102
148
 
103
149
  - Treat implementation and tests as the source of truth for current behavior.
104
- - Product documentation may establish intent but is not proof of implemented behavior.
105
- - Do not claim functionality works merely because a type, route, UI control, configuration key, placeholder, or planning entry exists.
150
+ - Treat \`tailframe.json\` and the root pnpm workspace as the product architecture and dependency boundary.
151
+ - Read the nearest application \`AGENTS.md\` before changing files under \`apps/\`.
106
152
 
107
153
  ## Product boundary
108
154
 
109
- The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations. Add product-specific guidance only after an explicit decision or implementation establishes it.
155
+ The product domain is not yet defined. Do not invent domain entities, workflows, claims, roles, or integrations.
110
156
 
111
157
  ## Repository map
112
158
 
113
- This project root contains separate repositories. Do not assume that a convention from one repository applies to another.
114
-
115
- ${repos.map(([name, description]) => `- \`${name}/\` — ${description} Read \`${name}/AGENTS.md\` before editing it.`).join("\n")}
159
+ - \`apps/service/\`  Express/TypeScript API and background runtime.
160
+ ${options.ui ? "- \`apps/ui/\`  Vue/Vite customer interface.\n" : ""}- The product root owns Git history, the pnpm lockfile, Tailframe metadata, orchestration, CI, and deployment.
116
161
 
117
- ## Cross-repository changes
162
+ ## Cross-application changes
118
163
 
119
- 1. Read each applicable child \`AGENTS.md\`.
120
- 2. Inspect the nearest working implementation in each repository.
121
- 3. Keep shared contracts aligned across all affected clients and services.
122
- 4. Validate each repository with its own scripts.
123
- 5. Do not claim end-to-end behavior from changes or static checks in only one repository.
164
+ 1. Read the root and applicable application guidance.
165
+ 2. Keep shared wire contracts aligned across service and UI.
166
+ 3. Run focused checks while iterating and product-root checks before handoff.
167
+ 4. Do not claim end-to-end behavior from changes or static checks in only one application.
124
168
 
125
169
  ## Module workflow
126
170
 
127
- - Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability module.
128
- - This applies when work affects one repository, multiple repositories, or one phase of a feature whose backend and clients will be implemented separately.
129
- - Do not wait for a task to become cross-repository before using the skill.
130
- - Create architecture files with \`tailframe generate\` in the affected repository; the skill orchestrates the work, the CLI creates the files.
171
+ - Use the local \`new-module\` skill whenever creating or extending a domain module, public operation, persisted entity, or client capability.
172
+ - Create architecture files with \`tailframe generate --app service|ui\` from the product root.
131
173
 
132
174
  ## Code-change guidelines
133
175
 
134
- - Keep changes small and focused and avoid unrelated refactoring.
135
- - Modify existing structures when they fit the requirement.
136
- - Preserve unrelated worktree changes.
137
- - Confirm every committed change belongs to the requested scope.
176
+ - Keep changes focused and preserve unrelated worktree changes.
177
+ - Do not create nested repositories or package-manager boundaries under \`apps/\`.
138
178
 
139
179
  ## GitHub workflow
140
180
 
141
181
  - Use \`gh\` for GitHub interactions when it supports the action.
142
- - Inspect existing issues and pull requests before proposing duplicates.
143
182
  - Never mutate GitHub state without explicit authorization.
144
- - Verify external mutations with a read-only follow-up.
145
183
 
146
184
  ## GitHub authentication and sandboxing
147
185
 
148
- - All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt; do not try them inside the sandbox first.
149
- - Docker commands include \`docker\`, \`docker compose\`, \`docker exec\`, and package scripts whose purpose is to invoke Docker.
150
- - AWS/ECR commands include \`aws ecr\` authentication and package scripts that build or push ECR images.
151
- - Never print, log, commit, or include GitHub tokens in command output.
152
- - Keep mutation approvals separate from read-only GitHub access.
153
-
154
- ## Starting work from a GitHub issue
155
-
156
- Resolve and read the issue and comments, load applicable guidance, inspect worktree state and related work, determine scope and validation, preserve unrelated changes, and use an isolated branch for implementation. Starting implementation does not authorize changing issue metadata. Report the issue, repositories, branch, scope, checks, preserved changes, and blockers.
186
+ - All \`gh\`, AWS/ECR, and Docker commands must run outside the sandbox. Request approval before the first attempt.
187
+ - Never print, log, commit, or include credentials or tokens in command output.
157
188
  `);
158
-
159
189
  add(".agents/skills/new-module/SKILL.md", `---
160
190
  name: new-module
161
- description: Create or extend a domain-colocated module in one repository or across the optional service and UI repositories using explicit use cases, separate trusted request context, module-owned repository ports and persistence adapters, HTTP validation, and focused tests. Use for backend capabilities, HTTP operations, persisted entities, queries, commands, jobs, workers, frontend capability modules, or phased work where a client is implemented later.
191
+ description: Create or extend a domain-colocated module in the service and optional UI applications in one product repository using explicit use cases, separate trusted request context, module-owned repository ports and persistence adapters, HTTP validation, and focused tests. Use for backend capabilities, HTTP operations, persisted entities, queries, commands, jobs, workers, frontend capability modules, or phased work where a client is implemented later.
162
192
  ---
163
193
 
164
194
  # New module
165
195
 
166
196
  Use this workflow for any new domain capability or operation. Add only the layers earned by actual behavior.
167
197
 
168
- 1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
198
+ 1. Identify the module, requested operations, entry points, and applications in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
169
199
  2. Read the root and every applicable child \`AGENTS.md\`.
170
200
  3. Inspect the nearest working module and tests.
171
- 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Service module generation also registers the new use case and mounts or extends its single route factory through deterministic composition-file edits. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the remaining behavior-specific checklist.
172
- 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
201
+ 4. Create architecture files only with the tailframe CLI: \`tailframe generate --app <service|ui>\` creates canonical service and UI architecture files. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the behavior-specific checklist.
202
+ 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module; when the service exposes a user capability, its current-user record belongs to a UI \`user\` module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
173
203
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
174
204
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
175
205
  8. Define repository contracts in the owning module and implement them in persistence adapters.
176
- 9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
206
+ 9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate identifiers with \`tailframe generate --app service identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters.
177
207
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
178
- 11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/theme app stores and \`ThemeToggle\` from app; every other app path is private.
208
+ 11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/notification/theme app stores and \`ThemeToggle\` from app; every other app path is private.
179
209
  12. Verify generated container and route wiring, then add only dependency construction, client, navigation, worker, or process registration earned by real behavior. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
180
- 13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
210
+ 13. Run \`pnpm validate:architecture\` from the product root, then run focused type checks and tests. Report applications, operations, entry points, checks, placement decisions, and gaps.
181
211
 
182
212
  Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
183
213
  `);
@@ -189,25 +219,26 @@ const svcDeps = {
189
219
  joi: "^17.13.3",
190
220
  "reflect-metadata": "^0.2.2",
191
221
  tsyringe: "^4.9.1",
192
- mongodb: "^6.17.0",
222
+ ...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
193
223
  ...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
194
- ...(options.redis ? { redis: "^5.10.0" } : {})
224
+ ...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
195
225
  };
196
226
  const svcDevDeps = {
197
227
  "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^22.13.14", "@types/supertest": "^7.2.1",
198
- "@zaaxch/tailframe": contractVersion,
228
+ ...(postgres ? { "@types/pg": "^8.15.5" } : {}),
199
229
  jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
200
230
  "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
201
231
  };
202
232
  add(`${svc}/package.json`, JSON.stringify({
203
- name: svc, version: "0.1.0", private: true, main: "dist/server.js",
233
+ name: servicePackage, version: "0.1.0", private: true, main: "dist/server.js", files: ["dist"],
204
234
  engines: { node: ">=22.13.0" },
205
235
  scripts: {
206
236
  dev: "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/server.ts",
207
237
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
208
238
  build: "tsc && tsc-alias", start: "node dist/server.js",
209
239
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
210
- test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/", "format:check": "prettier --check src/",
240
+ test: "pnpm test:unit && pnpm test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate ../.. --app service", "sync:architecture": "tailframe sync --check ../..", format: "prettier --write src/", "format:check": "prettier --check src/",
241
+ "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
211
242
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
212
243
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
213
244
  "docker:prod": "docker compose -f docker-compose.yml up -d",
@@ -228,17 +259,23 @@ add(`${svc}/.gitattributes`, gitAttributes);
228
259
  add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
229
260
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
230
261
  if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
231
- const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
262
+ const dbEnv = postgres
263
+ ? `POSTGRES_URI=postgresql://${options.name}-app:development@localhost:5432/${databaseName}`
264
+ : "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
232
265
  add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https://localhost:5173\n${dbEnv}\n${options.auth === "firebase" ? "FIREBASE_PROJECT_ID=\n" : ""}${options.redis ? "REDIS_URL=redis://localhost:6379\n" : ""}`);
233
266
  add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
234
- IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
267
+ IMAGE_TAG=<product-sha>
235
268
  APP_ENV_FILE=/opt/${options.name}/shared/.env.production
236
- MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
269
+ ${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
270
+ POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
271
+ POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
272
+ ` : `MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
237
273
  MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
238
274
  MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
239
275
  MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
240
276
  MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
241
277
  MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
278
+ `}
242
279
  ${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
243
280
  ` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
244
281
  REDIS_PASSWORD=<different-uri-safe-random-value>
@@ -248,12 +285,25 @@ export const env = {
248
285
  nodeEnv: process.env.NODE_ENV ?? "development",
249
286
  port: Number(process.env.PORT ?? 3000),
250
287
  corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
251
- ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
252
- mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
253
- mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",
288
+ ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",
289
+ firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
290
+ ${postgres ? `postgresUri: process.env.POSTGRES_URI ?? "postgresql://${options.name}-app:development@localhost:5432/${databaseName}",` : `mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
291
+ mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
254
292
  ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
255
293
  };`);
256
- const databaseSource = `import { MongoClient } from "mongodb";
294
+ const databaseSource = postgres ? `import { Pool } from "pg";
295
+ import { env } from "@/platform/config/env";
296
+
297
+ export const postgres = new Pool({ connectionString: env.postgresUri });
298
+
299
+ export async function connectDatabase() {
300
+ await postgres.query("SELECT 1");
301
+ return postgres;
302
+ }
303
+
304
+ export async function closeDatabase() {
305
+ await postgres.end();
306
+ }` : `import { MongoClient } from "mongodb";
257
307
  import { env } from "@/platform/config/env";
258
308
 
259
309
  export const mongo = new MongoClient(env.mongodbUri);
@@ -267,6 +317,15 @@ export async function closeDatabase() {
267
317
  await mongo.close();
268
318
  }`;
269
319
  add(`${svc}/src/platform/database/index.ts`, databaseSource);
320
+ add(`${svc}/src/app/schema.ts`, `import type { SchemaLifecycle } from "@/core/SchemaLifecycle";
321
+ import { closeDatabase, connectDatabase } from "@/platform/database";
322
+
323
+ export const schemaLifecycle: SchemaLifecycle = {
324
+ async apply() {
325
+ await connectDatabase();
326
+ },
327
+ close: closeDatabase
328
+ };`);
270
329
  if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
271
330
  add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
272
331
  add(`${svc}/src/core/UseCase.ts`, useCaseSource);
@@ -283,9 +342,10 @@ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
283
342
  add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
284
343
  add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
285
344
  add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
286
- add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
345
+ if (postgres) add(`${svc}/src/platform/integrations/postgres/PostgresReadinessProbe.ts`, postgresReadinessProbeSource);
346
+ else add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
287
347
  if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
288
- add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
348
+ add(`${svc}/src/app/container.ts`, containerSource({ database: options.db, redis: options.redis }));
289
349
  add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
290
350
  add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
291
351
  add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
@@ -293,13 +353,34 @@ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource())
293
353
  if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
294
354
  add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
295
355
  add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
296
- add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
356
+ add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
357
+ const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
358
+ export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
359
+ export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
360
+ export async function closeTestDatabase() { await pool.end(); }
361
+ ` : `import { MongoClient } from "mongodb";
297
362
  const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
298
363
  export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
299
364
  export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
300
365
  export async function closeTestDatabase() { await client.close(); }
301
366
  `);
302
- add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
367
+ add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
368
+ services:
369
+ postgres:
370
+ image: postgres:17
371
+ ports:
372
+ - "5433:5432"
373
+ environment:
374
+ POSTGRES_DB: ${databaseName}_test
375
+ POSTGRES_PASSWORD: postgres
376
+ tmpfs:
377
+ - /var/lib/postgresql/data
378
+ healthcheck:
379
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}_test"]
380
+ interval: 2s
381
+ timeout: 2s
382
+ retries: 30
383
+ ` : `name: ${options.name}-test
303
384
  services:
304
385
  mongo:
305
386
  image: mongo:8
@@ -410,7 +491,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
410
491
  | --- | --- |
411
492
  | Product rule, query, command, or policy | \`src/modules/<module>/use-cases\` or \`domain\` |
412
493
  | Repository port | Owning module's \`use-cases/ports\` |
413
- | MongoDB implementation | Owning module's \`persistence\` |
494
+ | Database implementation | Owning module's \`persistence\` |
414
495
  | Express route or request schema | Owning module's \`http\` |
415
496
  | Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
416
497
  | Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
@@ -444,11 +525,13 @@ Product vocabulary stays in the module that owns its meaning; never move it into
444
525
  - Define repository contracts under the owning module's \`use-cases/ports\`.
445
526
  - Implement database-specific adapters under that module's \`persistence/\` directory.
446
527
  - For persisted capabilities, prefer branded, domain-owned string IDs in module types and repository ports. A technology-neutral \`Brand<Value, Tag>\` helper MAY be defined when a real module needs distinct ID types; do not create product entities or concrete ID types in the scaffold.
447
- - Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only document types and MUST NOT use \`any\` to bypass the domain/persistence distinction. MongoDB \`ObjectId\` conversion belongs inside the MongoDB persistence adapter.
528
+ - Repository ports and use cases MUST NOT expose database identifier types. Persistence adapters SHOULD define persistence-only storage types and MUST NOT use \`any\` to bypass the domain/persistence distinction. Conversion between domain and database representations belongs inside the database persistence adapter.
448
529
  - Add appropriate schema or validation, indexes, initialization or migrations, and focused tests.
449
530
 
450
531
  ## Project-specific persistence
451
- This service uses MongoDB. Module-owned MongoDB adapters own collection access, persistence-only document types, and conversion of branded module string IDs to and from ObjectId inside the adapter, along with queries, projections, validators, and indexes. Do not use any to bypass the domain/persistence distinction.
532
+ ${postgres
533
+ ? "This service uses PostgreSQL. Module-owned PostgreSQL adapters own table access, persistence-only row types, queries, projections, transactions, and conversion to domain types. Keep schema definitions and migrations with the owning module's persistence implementation, and never expose database types or use any to bypass repository ports."
534
+ : "This service uses MongoDB. Module-owned MongoDB adapters own collection access, persistence-only document types, and conversion of branded module string IDs to and from ObjectId inside the adapter, along with queries, projections, validators, and indexes. Do not use any to bypass the domain/persistence distinction."}
452
535
 
453
536
  ## Queries and commands
454
537
  - Commands enforce invariants and change state.
@@ -464,19 +547,20 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
464
547
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
465
548
 
466
549
  ## Production image
467
- The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
550
+ The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
468
551
 
469
552
  ## Production deployment
470
553
  Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
471
554
 
472
555
  ## Tests and validation
473
- Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
556
+ Run \`pnpm validate:architecture\` and \`pnpm format:check\` after changing files or imports. The generated service also includes unit tests and ${postgres ? "PostgreSQL-backed" : "Mongo-backed"} integration tests. Run focused service database tests through pnpm for the full service check. Test use cases, policies, ${postgres ? "PostgreSQL" : "Mongo"} repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need ${postgres ? "PostgreSQL" : "Mongo"} ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including the service \`docker:push\` script, must run outside the sandbox from the first attempt.
474
557
  `);
475
558
 
476
559
  if (options.ui) {
477
560
  const uiDeps = { "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.3.0", axios: "^1.9.0", pinia: "^3.0.1", primevue: "^4.2.5", vue: "^3.5.13", "vue-router": "^4.5.0", ...(options.auth === "firebase" ? { firebase: "^11.0.0" } : {}) };
478
- const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@zaaxch/tailframe": contractVersion, "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
479
- add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest run", "test:watch": "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
561
+ const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
562
+ add(`${ui}/package.json`, JSON.stringify({ name: uiPackage, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest run", "test:watch": "vitest", "validate:architecture": "tailframe validate ../.. --app ui", "sync:architecture": "tailframe sync --check ../..", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
563
+
480
564
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
481
565
  add(`${ui}/.gitattributes`, gitAttributes);
482
566
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
@@ -515,8 +599,10 @@ export default createRouter({
515
599
  });`);
516
600
  add(`${ui}/src/platform/http.ts`, uiHttpSource);
517
601
  if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
602
+ add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
518
603
  add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
519
604
  add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
605
+ import Button from "primevue/button";
520
606
  import { ref } from "vue";
521
607
  import { isServiceError } from "@/core/errors";
522
608
  import { getReadiness } from "@/modules/health/api/health.api";
@@ -544,35 +630,43 @@ async function checkReadiness() {
544
630
  <p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
545
631
  <h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
546
632
  <p>The product domain is intentionally undefined.</p>
547
- <button
633
+ <Button
548
634
  type="button"
635
+ :label="loading ? 'Checking…' : 'Check readiness'"
549
636
  class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
550
637
  :disabled="loading"
638
+ :loading="loading"
551
639
  @click="checkReadiness"
552
- >
553
- {{ loading ? "Checking…" : "Check readiness" }}
554
- </button>
640
+ />
555
641
  <p v-if="status" role="status">Service status: {{ status }}</p>
556
642
  <p v-if="error" role="alert">{{ error }}</p>
557
643
  </main>
558
644
  </template>`);
559
- add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
645
+ add(`${ui}/src/app/App.vue`, `<template>
646
+ <RouterView />
647
+ <NotificationHost />
648
+ </template>
649
+
650
+ <script setup lang="ts">
651
+ import { RouterView } from "vue-router";
652
+ import NotificationHost from "@/app/components/NotificationHost.vue";
653
+ </script>`);
560
654
  add(`${ui}/src/app/startApplication.ts`, `import { createApp } from "vue";\nimport { createPinia } from "pinia";\nimport PrimeVue from "primevue/config";\nimport App from "@/app/App.vue";\n${options.auth === "firebase" ? 'import { configureHttp } from "@/app/configureHttp";\n' : ""}import router from "@/app/router";\nimport "@/app/styles.css";\nexport function startApplication() {\n\tconst app = createApp(App);\n\tconst pinia = createPinia();\n\tapp.use(pinia).use(PrimeVue).use(router);\n\t${options.auth === "firebase" ? "configureHttp(pinia);\n\t" : ""}app.mount("#app");\n}\n`);
561
655
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
562
656
  add(`${ui}/src/app/styles.css`, `@import "tailwindcss";\n:root { font-family: Inter, ui-sans-serif, system-ui; color: #17221c; background: #f5f3ec; }\nbody { margin: 0; }`);
563
- add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";\nimport App from "@/app/App.vue";\ndescribe("App", () => { it("renders a router view", () => { expect(mount(App, { global: { stubs: ["RouterView"] } }).exists()).toBe(true); }); });`);
657
+ add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";\nimport App from "@/app/App.vue";\ndescribe("App", () => { it("renders a router view", () => { expect(mount(App, { global: { stubs: ["RouterView", "NotificationHost"] } }).exists()).toBe(true); }); });`);
564
658
  add(`${ui}/AGENTS.md`, `# ${title} UI guidance
565
659
 
566
660
  ## Scope
567
661
  These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
568
662
 
569
663
  ## Architecture vocabulary
570
- - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
664
+ - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/notification/theme catalog.
571
665
  - Use \`src/core\` only for small technology-neutral client contracts.
572
666
  - Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
573
667
  - Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
574
- - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
575
- - Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
668
+ - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\`, \`notification.store.ts\`, and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
669
+ - Never create \`src/modules/theme\`, \`src/modules/auth\`, or \`src/modules/notification\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module. When the service exposes a user capability, its current-user record belongs to a UI \`user\` module rather than whichever consumer happens to use fields from the response.
576
670
 
577
671
  ## Change placement
578
672
  | Change | Canonical owner |
@@ -580,14 +674,14 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
580
674
  | Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
581
675
  | Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
582
676
  | Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
583
- | Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
677
+ | Firebase identity/readiness, notification queue, or theme preference | Generated \`src/app/stores/auth.store.ts\`, \`notification.store.ts\`, or \`theme.store.ts\` |
584
678
  | Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
585
679
  | Any other state or reusable presentation | Owning \`src/modules/<module>\` |
586
680
  | Shell, router, global styles, or cross-capability composition | \`src/app\` |
587
681
  | Technology-neutral client contract | Flat \`src/core\` |
588
682
  | Root executable | Bootstrap of \`src/app\` only |
589
683
 
590
- UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
684
+ UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`notification.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
591
685
 
592
686
  ## Module shape
593
687
  Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`public/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Public entries are named direct files, never nested directories or barrels. Do not invent alternative layer names or generate speculative CRUD. Create module and public shell files only with \`tailframe generate\`.
@@ -602,10 +696,11 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
602
696
 
603
697
  ## State management
604
698
  Keep local state local. Use Pinia only for state shared across routes or unrelated components.
605
- Keep all product state in the owning module. The auth/theme stores and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
699
+ Keep all product state in the owning module. The auth/notification/theme stores, NotificationHost, and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
606
700
 
607
701
  ## UI behavior
608
- Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
702
+ - Prefer PrimeVue for standard interactive controls and overlays when an appropriate component exists. Use native HTML for semantic structure, links, and genuinely custom interactions; use Tailwind for layout and composition.
703
+ - Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
609
704
 
610
705
  ## Styling
611
706
  - Prefer Tailwind utility classes in Vue templates over custom selectors and component-scoped CSS.
@@ -613,74 +708,44 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
613
708
  - Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
614
709
 
615
710
  ## Production packaging
616
- The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined \`linux/amd64\` production image under the default immutable \`<service-sha>_<ui-sha>\` tag, and production Compose requires that exact tag.${options.auth === "firebase" ? ` Provide \`${ui}/.env.production\` before a production build; the service build mounts it as a required BuildKit secret only while Vite compiles the browser bundle and does not copy it into the final image.` : ""}
711
+ The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this application and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined \`linux/amd64\` production image under the default immutable \`<product-sha>\` tag, and production Compose requires that exact tag.${options.auth === "firebase" ? ` Provide \`${ui}/.env.production\` before a production build; the service build mounts it as a required BuildKit secret only while Vite compiles the browser bundle and does not copy it into the final image.` : ""}
617
712
 
618
713
  ## Product language
619
714
  The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
620
715
 
621
716
  ## Validation
622
- Run \`npm run validate:architecture\` and \`npm run format:check\`, then add focused Vitest coverage and run type-check, build, and \`npm test\` as applicable. Use \`npm run test:watch\` only for interactive development.
717
+ Run \`pnpm validate:architecture\` and \`pnpm format:check\`, then add focused Vitest coverage and run type-check, build, and \`pnpm test\` as applicable. Use \`pnpm test:watch\` only for interactive development.
623
718
  `);
624
719
  }
625
720
 
626
- add(`${svc}/Dockerfile`, options.ui ? `FROM node:22.13-bookworm AS service-build
627
- WORKDIR /app/service
628
- COPY ${svc}/package*.json ./
629
- RUN npm ci
630
- COPY ${svc}/ ./
631
- RUN npm run build
632
-
633
- FROM node:22.13-bookworm AS service-production-dependencies
634
- WORKDIR /app/service
635
- COPY ${svc}/package*.json ./
636
- RUN npm ci --omit=dev && npm cache clean --force
637
-
638
- FROM node:22.13-bookworm AS ui-build
639
- WORKDIR /app/ui
640
- COPY ${ui}/package*.json ./
641
- RUN npm ci
642
- COPY ${ui}/ ./
643
- ${options.auth === "firebase" ? "RUN --mount=type=secret,id=ui_env,target=/app/ui/.env.production,required=true npm run build" : "RUN npm run build"}
644
-
645
- FROM node:22.13-bookworm-slim
646
- WORKDIR /app
647
- ENV NODE_ENV=production
648
- COPY --from=service-build /app/service/dist ./dist
649
- COPY --from=service-build /app/service/package*.json ./
650
- COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
651
- COPY --from=ui-build /app/ui/dist ./dist/public
652
- USER node
653
- EXPOSE 3000
654
- CMD ["node", "dist/server.js"]
655
- ` : `FROM node:22.13-bookworm AS build
656
- WORKDIR /app
657
- COPY package*.json ./
658
- RUN npm ci
659
- COPY . .
660
- RUN npm run build
661
-
662
- FROM node:22.13-bookworm AS production-dependencies
663
- WORKDIR /app
664
- COPY package*.json ./
665
- RUN npm ci --omit=dev && npm cache clean --force
721
+ add(`${svc}/Dockerfile`, `FROM node:22.13-bookworm AS build
722
+ RUN corepack enable
723
+ WORKDIR /workspace
724
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
725
+ COPY apps/service/package.json apps/service/package.json
726
+ ${options.ui ? "COPY apps/ui/package.json apps/ui/package.json\n" : ""}RUN pnpm install --frozen-lockfile
727
+ COPY apps/service apps/service
728
+ ${options.ui ? "COPY apps/ui apps/ui\n" : ""}RUN pnpm --filter ${servicePackage} build
729
+ ${options.ui ? (options.auth === "firebase" ? `RUN --mount=type=secret,id=ui_env,target=/workspace/apps/ui/.env.production,required=true pnpm --filter ${uiPackage} build` : `RUN pnpm --filter ${uiPackage} build`) + "\n" : ""}RUN pnpm --filter ${servicePackage} --prod deploy /prod/service
666
730
 
667
731
  FROM node:22.13-bookworm-slim
668
732
  WORKDIR /app
669
733
  ENV NODE_ENV=production
670
- COPY --from=build /app/dist ./dist
671
- COPY --from=build /app/package*.json ./
672
- COPY --from=production-dependencies /app/node_modules ./node_modules
673
- USER node
734
+ COPY --from=build /prod/service ./
735
+ ${options.ui ? "COPY --from=build /workspace/apps/ui/dist ./dist/public\n" : ""}USER node
674
736
  EXPOSE 3000
675
737
  CMD ["node", "dist/server.js"]
676
738
  `);
677
739
  add(`${svc}/Dockerfile.dev`, `FROM node:22.13-alpine
678
- WORKDIR /app
679
- COPY package*.json ./
680
- RUN npm install
681
- COPY . .
740
+ RUN corepack enable
741
+ WORKDIR /workspace
742
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
743
+ COPY apps/service/package.json apps/service/package.json
744
+ RUN pnpm install --frozen-lockfile
745
+ COPY apps/service apps/service
746
+ WORKDIR /workspace/apps/service
682
747
  EXPOSE 3000
683
- CMD ["npm", "run", "dev"]
748
+ CMD ["pnpm", "dev"]
684
749
  `);
685
750
  add(`${svc}/scripts/build_and_push.sh`, `#!/usr/bin/env bash
686
751
  set -euo pipefail
@@ -688,54 +753,44 @@ set -euo pipefail
688
753
  : "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
689
754
  : "\${AWS_REGION:?Set AWS_REGION}"
690
755
 
691
- SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
692
- BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
693
- ${options.ui ? `UI_DIR="\${BUILD_CONTEXT}/${ui}"
694
- SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"
695
- UI_SHA="$(git -C "\${UI_DIR}" rev-parse --short HEAD)"` : `SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"`}
696
-
756
+ SERVICE_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
757
+ PRODUCT_ROOT="$(cd "\${SERVICE_DIR}/../.." && pwd)"
758
+ PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse --short HEAD)"
697
759
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
698
- IMAGE_TAG="\${IMAGE_TAG:-${options.ui ? `\${SVC_SHA}_\${UI_SHA}` : `\${SVC_SHA}`}}"
760
+ IMAGE_TAG="\${IMAGE_TAG:-\${PRODUCT_SHA}}"
699
761
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
700
762
  IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
701
- DOCKERFILE="\${SVC_DIR}/Dockerfile"
702
- ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${UI_DIR}/.env.production}"` : ""}
763
+ DOCKERFILE="\${SERVICE_DIR}/Dockerfile"
764
+ ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${PRODUCT_ROOT}/apps/ui/.env.production}"` : ""}
703
765
 
704
766
  aws ecr get-login-password --region "\${AWS_REGION}" |
705
767
  docker login --username AWS --password-stdin "\${REGISTRY}"
706
768
 
707
- docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
769
+ docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${PRODUCT_ROOT}"
708
770
  docker push "\${IMAGE}"
709
771
 
710
772
  echo "Pushed \${IMAGE}"
711
773
  `);
712
-
713
- const workflowUiCheckout = options.ui ? `
714
- - name: Checkout UI dependency
715
- uses: actions/checkout@v6
716
- with:
717
- repository: \${{ github.repository_owner }}/${ui}
718
- ref: main
719
- ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
720
- path: ${ui}
721
- ` : "";
774
+ const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
722
775
  const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
723
776
  - name: Write UI production environment
724
777
  env:
725
778
  UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
726
779
  run: |
727
- printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
780
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > apps/ui/.env.production
728
781
  ` : "";
729
- const workflowTagResolution = options.ui ? `
730
- ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
731
- echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
732
- echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
733
- add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
782
+ add(".github/workflows/deploy.yml", `name: Build and deploy
734
783
 
735
784
  on:
736
785
  push:
737
786
  branches:
738
787
  - main
788
+ paths:
789
+ - "apps/service/**"
790
+ ${workflowUiPath} - "package.json"
791
+ - "pnpm-workspace.yaml"
792
+ - "pnpm-lock.yaml"
793
+ - ".github/workflows/deploy.yml"
739
794
  workflow_dispatch:
740
795
 
741
796
  permissions:
@@ -760,15 +815,14 @@ jobs:
760
815
  DEPLOY_USER: \${{ vars.DEPLOY_USER }}
761
816
 
762
817
  steps:
763
- - name: Checkout service
818
+ - name: Checkout product
764
819
  uses: actions/checkout@v6
765
- with:
766
- path: ${svc}
767
- ${workflowUiCheckout}${workflowUiEnvironment}
820
+ ${workflowUiEnvironment}
768
821
  - name: Resolve immutable image
769
822
  id: image
770
823
  run: |
771
- svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
824
+ product_sha="$(git rev-parse --short HEAD)"
825
+ echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
772
826
  echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
773
827
  echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
774
828
 
@@ -779,10 +833,9 @@ ${workflowUiCheckout}${workflowUiEnvironment}
779
833
  aws-region: \${{ vars.AWS_REGION }}
780
834
 
781
835
  - name: Build and push image
782
- working-directory: ${svc}
783
836
  env:
784
837
  IMAGE_TAG: \${{ steps.image.outputs.tag }}
785
- run: ./scripts/build_and_push.sh
838
+ run: apps/service/scripts/build_and_push.sh
786
839
 
787
840
  - name: Configure deployment SSH
788
841
  env:
@@ -805,11 +858,11 @@ ${workflowUiCheckout}${workflowUiEnvironment}
805
858
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
806
859
  "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
807
860
  scp -i ~/.ssh/${options.name}-deploy \\
808
- ${svc}/docker-compose.yml \\
809
- ${svc}/scripts/deploy_remote.sh \\
861
+ apps/service/docker-compose.yml \\
862
+ apps/service/scripts/deploy_remote.sh \\
810
863
  "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
811
864
  scp -i ~/.ssh/${options.name}-deploy \\
812
- ${svc}/deploy/mongo/10-create-users.js \\
865
+ apps/service/deploy/mongo/10-create-users.js \\
813
866
  "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
814
867
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
815
868
  "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
@@ -819,7 +872,6 @@ ${workflowUiCheckout}${workflowUiEnvironment}
819
872
  ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
820
873
  "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
821
874
  `);
822
-
823
875
  add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
824
876
  set -euo pipefail
825
877
 
@@ -852,33 +904,28 @@ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
852
904
  docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
853
905
  `);
854
906
 
855
- add(`${svc}/deploy/mongo/10-create-users.js`, `const applicationPassword = process.env.MONGODB_APP_PASSWORD;
856
- const backupPassword = process.env.MONGODB_BACKUP_PASSWORD;
857
-
858
- if (!applicationPassword || !backupPassword) {
859
- throw new Error("MONGODB_APP_PASSWORD and MONGODB_BACKUP_PASSWORD are required");
860
- }
861
-
862
- db.getSiblingDB("${databaseName}").createUser({
863
- user: "${mongoApplicationUser}",
864
- pwd: applicationPassword,
865
- roles: [{ role: "readWrite", db: "${databaseName}" }]
866
- });
907
+ if (postgres) add(`${svc}/deploy/postgres/10-create-application-user.sh`, `#!/usr/bin/env bash
908
+ set -euo pipefail
867
909
 
868
- db.getSiblingDB("admin").createUser({
869
- user: "${mongoBackupUser}",
870
- pwd: backupPassword,
871
- roles: [{ role: "backup", db: "admin" }]
872
- });
910
+ psql --set ON_ERROR_STOP=1 --username postgres --dbname "${databaseName}" --set app_password="\${POSTGRES_APP_PASSWORD}" <<'SQL'
911
+ SELECT 'CREATE ROLE "${options.name}-app" LOGIN PASSWORD ' || quote_literal(:'app_password')
912
+ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.name}-app')\gexec
913
+ GRANT CONNECT ON DATABASE "${databaseName}" TO "${options.name}-app";
914
+ GRANT USAGE ON SCHEMA public TO "${options.name}-app";
915
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${options.name}-app";
916
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "${options.name}-app";
917
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "${options.name}-app";
918
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "${options.name}-app";
919
+ SQL
873
920
  `);
874
921
 
875
922
  add(`${svc}/docs/production-deployment.md`, `# Production deployment
876
923
 
877
924
  ## Delivery model
878
925
 
879
- GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
880
- non-root production image, tags it ${options.ui ? "`<service-sha>_<ui-sha>`" : "`<service-sha>`"}, pushes it to ECR, and installs only deployment artifacts on the host.
881
- Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
926
+ GitHub Actions checks out the product once, builds one
927
+ non-root production image, tags it \`<product-sha>\`, pushes it to ECR, and installs only deployment artifacts on the host.
928
+ Changes to the service, optional UI, root workspace/lock configuration, or deployment files on product \`main\`, plus manual dispatch, trigger this workflow.
882
929
 
883
930
  The host separates replaceable artifacts from persistent state:
884
931
 
@@ -906,7 +953,7 @@ Actions may replace \`current/\` and must never replace \`shared/\`. The server
906
953
  \`.env.infrastructure.example\` and replace every placeholder.
907
954
  - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
908
955
  credential paths.
909
- ${options.ui && options.auth === "firebase" ? `- The UI repository's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
956
+ ${options.ui && options.auth === "firebase" ? `- The UI application's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
910
957
  ` : ""}
911
958
  Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
912
959
 
@@ -934,8 +981,7 @@ Configure these variables:
934
981
 
935
982
  Configure these secrets:
936
983
 
937
- ${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
938
- ` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
984
+ ${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
939
985
  ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
940
986
  - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
941
987
 
@@ -964,24 +1010,30 @@ const devVolumes = [
964
1010
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
965
1011
  ];
966
1012
  const productionDependencies = [
967
- ["mongo-init", "service_completed_successfully"],
1013
+ ["schema-init", "service_completed_successfully"],
968
1014
  ...(options.redis ? [["redis", "service_healthy"]] : [])
969
1015
  ];
970
1016
  const developmentDependencies = [
971
- ["mongo-init", "service_completed_successfully"],
1017
+ ["schema-init", "service_completed_successfully"],
972
1018
  ...(options.redis ? [["redis", "service_started"]] : [])
973
1019
  ];
974
1020
  const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
975
1021
  const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
976
1022
  const productionEnvironment = [
977
1023
  " NODE_ENV: production",
978
- ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
979
- ` MONGODB_DB_NAME: "${databaseName}"`,
1024
+ ...(postgres
1025
+ ? [` POSTGRES_URI: "postgresql://${options.name}-app:\${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}@postgres:5432/${databaseName}"`]
1026
+ : [
1027
+ ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
1028
+ ` MONGODB_DB_NAME: "${databaseName}"`
1029
+ ]),
980
1030
  ...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
981
1031
  ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
982
1032
  ];
983
1033
  const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
984
- const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
1034
+ const databaseEnvironmentBlock = postgres
1035
+ ? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1036
+ : `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
985
1037
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
986
1038
  const workerService = options.worker ? `\n worker:
987
1039
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
@@ -998,7 +1050,25 @@ const workerService = options.worker ? `\n worker:
998
1050
  networks:
999
1051
  - backend
1000
1052
  restart: unless-stopped` : "";
1001
- const databaseService = `\n mongodb:
1053
+ const databaseService = postgres ? `\n postgres:
1054
+ image: postgres:17
1055
+ environment:
1056
+ POSTGRES_DB: "${databaseName}"
1057
+ POSTGRES_USER: postgres
1058
+ POSTGRES_PASSWORD: \${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}
1059
+ POSTGRES_APP_PASSWORD: \${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}
1060
+ volumes:
1061
+ - \${POSTGRES_DATA_DIR:?Set POSTGRES_DATA_DIR}:/var/lib/postgresql/data
1062
+ - ./deploy/postgres/10-create-application-user.sh:/docker-entrypoint-initdb.d/10-create-application-user.sh:ro
1063
+ healthcheck:
1064
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]
1065
+ interval: 5s
1066
+ timeout: 5s
1067
+ retries: 30
1068
+ start_period: 20s
1069
+ networks:
1070
+ - backend
1071
+ restart: unless-stopped` : `\n mongodb:
1002
1072
  image: mongo:8
1003
1073
  command:
1004
1074
  - mongod
@@ -1011,8 +1081,12 @@ const databaseService = `\n mongodb:
1011
1081
  MONGO_INITDB_DATABASE: "${databaseName}"
1012
1082
  MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1013
1083
  MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
1084
+ MONGODB_DB_NAME: "${databaseName}"
1085
+ MONGODB_APP_USERNAME: "${mongoApplicationUser}"
1014
1086
  MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
1087
+ MONGODB_BACKUP_USERNAME: "${mongoBackupUser}"
1015
1088
  MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
1089
+ MONGODB_APP_COLLECTIONS: "_tailframe_reserved"
1016
1090
  volumes:
1017
1091
  - \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
1018
1092
  - \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
@@ -1059,6 +1133,17 @@ const databaseService = `\n mongodb:
1059
1133
  }"
1060
1134
  networks:
1061
1135
  - backend`;
1136
+ const schemaInitDependencies = postgres ? [["postgres", "service_healthy"]] : [["mongo-init", "service_completed_successfully"]];
1137
+ const schemaInitDependsBlock = `\n depends_on:\n${schemaInitDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
1138
+ const schemaInitEnvironment = postgres
1139
+ ? `\n environment:\n POSTGRES_URI: "postgresql://postgres:\${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}@postgres:5432/${databaseName}"`
1140
+ : `\n environment:\n MONGODB_URI: "mongodb://${options.name}-root:\${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=admin"\n MONGODB_DB_NAME: "${databaseName}"`;
1141
+ const schemaInitService = `\n schema-init:
1142
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
1143
+ command: ["node", "dist/app/cli/applySchema.js"]${schemaInitEnvironment}${schemaInitDependsBlock}
1144
+ networks:
1145
+ - backend
1146
+ restart: "no"`;
1062
1147
  const redisService = options.redis ? `\n redis:
1063
1148
  image: redis:7-alpine
1064
1149
  command:
@@ -1109,7 +1194,7 @@ services:
1109
1194
  stop_grace_period: 2m
1110
1195
  networks:
1111
1196
  - backend
1112
- restart: unless-stopped${workerService}${databaseService}${redisService}
1197
+ restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
1113
1198
  networks:
1114
1199
  backend:
1115
1200
  driver: bridge
@@ -1124,7 +1209,21 @@ const devWorker = options.worker ? `\n worker:
1124
1209
  command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
1125
1210
  volumes:
1126
1211
  ${devVolumes.join("\n")}${devDependsBlock}` : "";
1127
- const devDatabase = `\n mongodb:
1212
+ const devDatabase = postgres ? `\n postgres:
1213
+ image: postgres:17
1214
+ ports:
1215
+ - "5432:5432"
1216
+ environment:
1217
+ POSTGRES_DB: ${databaseName}
1218
+ POSTGRES_USER: ${options.name}-app
1219
+ POSTGRES_PASSWORD: development
1220
+ volumes:
1221
+ - postgres-dev-data:/var/lib/postgresql/data
1222
+ healthcheck:
1223
+ test: ["CMD-SHELL", "pg_isready -U ${options.name}-app -d ${databaseName}"]
1224
+ interval: 2s
1225
+ timeout: 2s
1226
+ retries: 30` : `\n mongodb:
1128
1227
  image: mongo:8
1129
1228
  command: mongod --replSet rs0 --bind_ip_all
1130
1229
  ports:
@@ -1160,11 +1259,23 @@ const devRedis = options.redis ? `\n redis:
1160
1259
  volumes:
1161
1260
  - redis-dev-data:/data` : "";
1162
1261
  const devNamedVolumes = [
1163
- " mongodb-dev-data:",
1262
+ postgres ? " postgres-dev-data:" : " mongodb-dev-data:",
1164
1263
  ...(options.redis ? [" redis-dev-data:"] : [])
1165
1264
  ];
1166
1265
  add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
1167
1266
  services:
1267
+ schema-init:
1268
+ build:
1269
+ context: .
1270
+ dockerfile: Dockerfile.dev
1271
+ env_file:
1272
+ - .env.development
1273
+ command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
1274
+ volumes:
1275
+ ${devVolumes.join("\n")}
1276
+ depends_on:
1277
+ ${postgres ? "postgres" : "mongo-init"}:
1278
+ condition: ${postgres ? "service_healthy" : "service_completed_successfully"}
1168
1279
  ${options.name}:
1169
1280
  build:
1170
1281
  context: .
@@ -1179,6 +1290,15 @@ volumes:
1179
1290
  ${devNamedVolumes.join("\n")}
1180
1291
  `);
1181
1292
 
1293
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "service", profiles: serviceProfiles }))) {
1294
+ add(`${svc}/${relative}`, content);
1295
+ }
1296
+ if (options.ui) {
1297
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
1298
+ add(`${ui}/${relative}`, content);
1299
+ }
1300
+ }
1301
+
1182
1302
  for (const [relative, content] of Object.entries(files)) {
1183
1303
  const destination = path.join(root, relative);
1184
1304
  fs.mkdirSync(path.dirname(destination), { recursive: true });
@@ -1186,5 +1306,8 @@ for (const [relative, content] of Object.entries(files)) {
1186
1306
  if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
1187
1307
  }
1188
1308
 
1189
- return { root, name: options.name, database: "mongo", authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1309
+ const syncResult = runSync(root, "write", contractVersion);
1310
+ if (syncResult.errors.length) fail(syncResult.errors.join("\n"));
1311
+
1312
+ return { root, name: options.name, database: options.db, authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, applications: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1190
1313
  }