@zaaxch/tailframe 2.2.0 → 3.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
  }
@@ -64,6 +69,7 @@ const title = options.name.split("-").map((part) => part[0].toUpperCase() + part
64
69
  const svc = `${options.name}-svc`;
65
70
  const ui = `${options.name}-ui`;
66
71
  const databaseName = options.name.replaceAll("-", "_");
72
+ const postgres = options.db === "postgres";
67
73
  const mongoApplicationUser = `${options.name}-app`;
68
74
  const mongoBackupUser = `${options.name}-backup`;
69
75
  const files = {};
@@ -71,6 +77,13 @@ const add = (relative, content) => { files[relative] = content.endsWith("\n") ?
71
77
  // Generated repositories depend on the published toolkit rather than copying a validator, and they
72
78
  // pin the exact contract version they were generated against.
73
79
  const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
80
+ const serviceProfiles = [
81
+ ...(options.auth === "firebase" ? ["firebase"] : []),
82
+ options.db,
83
+ ...(options.redis ? ["redis", "rate-limit"] : []),
84
+ ...(options.worker ? ["worker"] : []),
85
+ ...(options.ui ? ["ui-host"] : [])
86
+ ];
74
87
  const prettierConfig = {
75
88
  useTabs: true,
76
89
  tabWidth: 4,
@@ -169,13 +182,13 @@ Use this workflow for any new domain capability or operation. Add only the layer
169
182
  2. Read the root and every applicable child \`AGENTS.md\`.
170
183
  3. Inspect the nearest working module and tests.
171
184
  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.
185
+ 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. 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
186
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
174
187
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
175
188
  8. Define repository contracts in the owning module and implement them in persistence adapters.
176
189
  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.
177
190
  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.
191
+ 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
192
  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
193
  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.
181
194
 
@@ -189,12 +202,13 @@ const svcDeps = {
189
202
  joi: "^17.13.3",
190
203
  "reflect-metadata": "^0.2.2",
191
204
  tsyringe: "^4.9.1",
192
- mongodb: "^6.17.0",
205
+ ...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
193
206
  ...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
194
- ...(options.redis ? { redis: "^5.10.0" } : {})
207
+ ...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
195
208
  };
196
209
  const svcDevDeps = {
197
210
  "@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",
211
+ ...(postgres ? { "@types/pg": "^8.15.5" } : {}),
198
212
  "@zaaxch/tailframe": contractVersion,
199
213
  jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
200
214
  "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
@@ -207,13 +221,15 @@ add(`${svc}/package.json`, JSON.stringify({
207
221
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
208
222
  build: "tsc && tsc-alias", start: "node dist/server.js",
209
223
  ...(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/",
224
+ 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/",
225
+ "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
211
226
  "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
227
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
213
228
  "docker:prod": "docker compose -f docker-compose.yml up -d",
214
229
  "docker:push": "bash scripts/build_and_push.sh"
215
230
  }, dependencies: svcDeps, devDependencies: svcDevDeps
216
231
  }, null, "\t"));
232
+ add(`${svc}/tailframe.json`, configSource({ kind: "service", profiles: serviceProfiles, contractVersion }));
217
233
  add(`${svc}/tsconfig.json`, JSON.stringify({
218
234
  compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
219
235
  include: ["src/**/*.ts"]
@@ -228,17 +244,23 @@ add(`${svc}/.gitattributes`, gitAttributes);
228
244
  add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
229
245
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
230
246
  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;
247
+ const dbEnv = postgres
248
+ ? `POSTGRES_URI=postgresql://${options.name}-app:development@localhost:5432/${databaseName}`
249
+ : "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
232
250
  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
251
  add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
234
252
  IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
235
253
  APP_ENV_FILE=/opt/${options.name}/shared/.env.production
236
- MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
254
+ ${postgres ? `POSTGRES_DATA_DIR=/opt/${options.name}/shared/postgres/data
255
+ POSTGRES_ROOT_PASSWORD=<uri-safe-random-value>
256
+ POSTGRES_APP_PASSWORD=<different-uri-safe-random-value>
257
+ ` : `MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
237
258
  MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
238
259
  MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
239
260
  MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
240
261
  MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
241
262
  MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
263
+ `}
242
264
  ${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
243
265
  ` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
244
266
  REDIS_PASSWORD=<different-uri-safe-random-value>
@@ -248,12 +270,25 @@ export const env = {
248
270
  nodeEnv: process.env.NODE_ENV ?? "development",
249
271
  port: Number(process.env.PORT ?? 3000),
250
272
  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}",
273
+ ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",
274
+ firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
275
+ ${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",
276
+ mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
254
277
  ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
255
278
  };`);
256
- const databaseSource = `import { MongoClient } from "mongodb";
279
+ const databaseSource = postgres ? `import { Pool } from "pg";
280
+ import { env } from "@/platform/config/env";
281
+
282
+ export const postgres = new Pool({ connectionString: env.postgresUri });
283
+
284
+ export async function connectDatabase() {
285
+ await postgres.query("SELECT 1");
286
+ return postgres;
287
+ }
288
+
289
+ export async function closeDatabase() {
290
+ await postgres.end();
291
+ }` : `import { MongoClient } from "mongodb";
257
292
  import { env } from "@/platform/config/env";
258
293
 
259
294
  export const mongo = new MongoClient(env.mongodbUri);
@@ -267,6 +302,15 @@ export async function closeDatabase() {
267
302
  await mongo.close();
268
303
  }`;
269
304
  add(`${svc}/src/platform/database/index.ts`, databaseSource);
305
+ add(`${svc}/src/app/schema.ts`, `import type { SchemaLifecycle } from "@/core/SchemaLifecycle";
306
+ import { closeDatabase, connectDatabase } from "@/platform/database";
307
+
308
+ export const schemaLifecycle: SchemaLifecycle = {
309
+ async apply() {
310
+ await connectDatabase();
311
+ },
312
+ close: closeDatabase
313
+ };`);
270
314
  if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
271
315
  add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
272
316
  add(`${svc}/src/core/UseCase.ts`, useCaseSource);
@@ -283,9 +327,10 @@ add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
283
327
  add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
284
328
  add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
285
329
  add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
286
- add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
330
+ if (postgres) add(`${svc}/src/platform/integrations/postgres/PostgresReadinessProbe.ts`, postgresReadinessProbeSource);
331
+ else add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
287
332
  if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
288
- add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
333
+ add(`${svc}/src/app/container.ts`, containerSource({ database: options.db, redis: options.redis }));
289
334
  add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
290
335
  add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
291
336
  add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
@@ -293,13 +338,34 @@ if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource())
293
338
  if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
294
339
  add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
295
340
  add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
296
- add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
341
+ add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
342
+ const pool = new Pool({ connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test" });
343
+ export async function openTestDatabase() { await pool.query("SELECT 1"); return pool; }
344
+ export async function resetTestDatabase() { await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); return pool; }
345
+ export async function closeTestDatabase() { await pool.end(); }
346
+ ` : `import { MongoClient } from "mongodb";
297
347
  const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
298
348
  export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
299
349
  export async function resetTestDatabase() { const database = await openTestDatabase(); await database.dropDatabase(); return database; }
300
350
  export async function closeTestDatabase() { await client.close(); }
301
351
  `);
302
- add(`${svc}/docker-compose.test.yml`, `name: ${options.name}-test
352
+ add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
353
+ services:
354
+ postgres:
355
+ image: postgres:17
356
+ ports:
357
+ - "5433:5432"
358
+ environment:
359
+ POSTGRES_DB: ${databaseName}_test
360
+ POSTGRES_PASSWORD: postgres
361
+ tmpfs:
362
+ - /var/lib/postgresql/data
363
+ healthcheck:
364
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}_test"]
365
+ interval: 2s
366
+ timeout: 2s
367
+ retries: 30
368
+ ` : `name: ${options.name}-test
303
369
  services:
304
370
  mongo:
305
371
  image: mongo:8
@@ -476,7 +542,12 @@ Run \`npm run validate:architecture\` and \`npm run format:check\` after changin
476
542
  if (options.ui) {
477
543
  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
544
  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"));
545
+ 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 .", "sync:architecture": "tailframe sync --check .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
546
+ add(`${ui}/tailframe.json`, configSource({
547
+ kind: "ui",
548
+ profiles: [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"],
549
+ contractVersion
550
+ }));
480
551
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
481
552
  add(`${ui}/.gitattributes`, gitAttributes);
482
553
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
@@ -515,6 +586,7 @@ export default createRouter({
515
586
  });`);
516
587
  add(`${ui}/src/platform/http.ts`, uiHttpSource);
517
588
  if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
589
+ add(`${ui}/src/platform/config.ts`, `export const apiBaseUrl = \`\${window.location.origin}/api/v1\`;`);
518
590
  add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
519
591
  add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
520
592
  import { ref } from "vue";
@@ -556,23 +628,31 @@ async function checkReadiness() {
556
628
  <p v-if="error" role="alert">{{ error }}</p>
557
629
  </main>
558
630
  </template>`);
559
- add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
631
+ add(`${ui}/src/app/App.vue`, `<template>
632
+ <RouterView />
633
+ <NotificationHost />
634
+ </template>
635
+
636
+ <script setup lang="ts">
637
+ import { RouterView } from "vue-router";
638
+ import NotificationHost from "@/app/components/NotificationHost.vue";
639
+ </script>`);
560
640
  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
641
  add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
562
642
  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); }); });`);
643
+ 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
644
  add(`${ui}/AGENTS.md`, `# ${title} UI guidance
565
645
 
566
646
  ## Scope
567
647
  These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
568
648
 
569
649
  ## Architecture vocabulary
570
- - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
650
+ - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/notification/theme catalog.
571
651
  - Use \`src/core\` only for small technology-neutral client contracts.
572
652
  - Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
573
653
  - 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.
654
+ - 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\`.
655
+ - 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.
576
656
 
577
657
  ## Change placement
578
658
  | Change | Canonical owner |
@@ -580,14 +660,14 @@ These conventions apply to \`${ui}/src\`. Inspect the nearest working module bef
580
660
  | Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
581
661
  | Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
582
662
  | 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\` |
663
+ | Firebase identity/readiness, notification queue, or theme preference | Generated \`src/app/stores/auth.store.ts\`, \`notification.store.ts\`, or \`theme.store.ts\` |
584
664
  | Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
585
665
  | Any other state or reusable presentation | Owning \`src/modules/<module>\` |
586
666
  | Shell, router, global styles, or cross-capability composition | \`src/app\` |
587
667
  | Technology-neutral client contract | Flat \`src/core\` |
588
668
  | Root executable | Bootstrap of \`src/app\` only |
589
669
 
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.
670
+ 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
671
 
592
672
  ## Module shape
593
673
  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,7 +682,7 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
602
682
 
603
683
  ## State management
604
684
  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.
685
+ 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
686
 
607
687
  ## UI behavior
608
688
  Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
@@ -852,24 +932,19 @@ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
852
932
  docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
853
933
  `);
854
934
 
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
- });
935
+ if (postgres) add(`${svc}/deploy/postgres/10-create-application-user.sh`, `#!/usr/bin/env bash
936
+ set -euo pipefail
867
937
 
868
- db.getSiblingDB("admin").createUser({
869
- user: "${mongoBackupUser}",
870
- pwd: backupPassword,
871
- roles: [{ role: "backup", db: "admin" }]
872
- });
938
+ psql --set ON_ERROR_STOP=1 --username postgres --dbname "${databaseName}" --set app_password="\${POSTGRES_APP_PASSWORD}" <<'SQL'
939
+ SELECT 'CREATE ROLE "${options.name}-app" LOGIN PASSWORD ' || quote_literal(:'app_password')
940
+ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${options.name}-app')\gexec
941
+ GRANT CONNECT ON DATABASE "${databaseName}" TO "${options.name}-app";
942
+ GRANT USAGE ON SCHEMA public TO "${options.name}-app";
943
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${options.name}-app";
944
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "${options.name}-app";
945
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "${options.name}-app";
946
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "${options.name}-app";
947
+ SQL
873
948
  `);
874
949
 
875
950
  add(`${svc}/docs/production-deployment.md`, `# Production deployment
@@ -964,24 +1039,30 @@ const devVolumes = [
964
1039
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
965
1040
  ];
966
1041
  const productionDependencies = [
967
- ["mongo-init", "service_completed_successfully"],
1042
+ ["schema-init", "service_completed_successfully"],
968
1043
  ...(options.redis ? [["redis", "service_healthy"]] : [])
969
1044
  ];
970
1045
  const developmentDependencies = [
971
- ["mongo-init", "service_completed_successfully"],
1046
+ ["schema-init", "service_completed_successfully"],
972
1047
  ...(options.redis ? [["redis", "service_started"]] : [])
973
1048
  ];
974
1049
  const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
975
1050
  const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
976
1051
  const productionEnvironment = [
977
1052
  " 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}"`,
1053
+ ...(postgres
1054
+ ? [` POSTGRES_URI: "postgresql://${options.name}-app:\${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}@postgres:5432/${databaseName}"`]
1055
+ : [
1056
+ ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
1057
+ ` MONGODB_DB_NAME: "${databaseName}"`
1058
+ ]),
980
1059
  ...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
981
1060
  ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
982
1061
  ];
983
1062
  const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
984
- const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
1063
+ const databaseEnvironmentBlock = postgres
1064
+ ? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1065
+ : `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
985
1066
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
986
1067
  const workerService = options.worker ? `\n worker:
987
1068
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
@@ -998,7 +1079,25 @@ const workerService = options.worker ? `\n worker:
998
1079
  networks:
999
1080
  - backend
1000
1081
  restart: unless-stopped` : "";
1001
- const databaseService = `\n mongodb:
1082
+ const databaseService = postgres ? `\n postgres:
1083
+ image: postgres:17
1084
+ environment:
1085
+ POSTGRES_DB: "${databaseName}"
1086
+ POSTGRES_USER: postgres
1087
+ POSTGRES_PASSWORD: \${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}
1088
+ POSTGRES_APP_PASSWORD: \${POSTGRES_APP_PASSWORD:?Set POSTGRES_APP_PASSWORD}
1089
+ volumes:
1090
+ - \${POSTGRES_DATA_DIR:?Set POSTGRES_DATA_DIR}:/var/lib/postgresql/data
1091
+ - ./deploy/postgres/10-create-application-user.sh:/docker-entrypoint-initdb.d/10-create-application-user.sh:ro
1092
+ healthcheck:
1093
+ test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]
1094
+ interval: 5s
1095
+ timeout: 5s
1096
+ retries: 30
1097
+ start_period: 20s
1098
+ networks:
1099
+ - backend
1100
+ restart: unless-stopped` : `\n mongodb:
1002
1101
  image: mongo:8
1003
1102
  command:
1004
1103
  - mongod
@@ -1011,8 +1110,12 @@ const databaseService = `\n mongodb:
1011
1110
  MONGO_INITDB_DATABASE: "${databaseName}"
1012
1111
  MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1013
1112
  MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
1113
+ MONGODB_DB_NAME: "${databaseName}"
1114
+ MONGODB_APP_USERNAME: "${mongoApplicationUser}"
1014
1115
  MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
1116
+ MONGODB_BACKUP_USERNAME: "${mongoBackupUser}"
1015
1117
  MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
1118
+ MONGODB_APP_COLLECTIONS: "_tailframe_reserved"
1016
1119
  volumes:
1017
1120
  - \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
1018
1121
  - \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
@@ -1059,6 +1162,17 @@ const databaseService = `\n mongodb:
1059
1162
  }"
1060
1163
  networks:
1061
1164
  - backend`;
1165
+ const schemaInitDependencies = postgres ? [["postgres", "service_healthy"]] : [["mongo-init", "service_completed_successfully"]];
1166
+ const schemaInitDependsBlock = `\n depends_on:\n${schemaInitDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
1167
+ const schemaInitEnvironment = postgres
1168
+ ? `\n environment:\n POSTGRES_URI: "postgresql://postgres:\${POSTGRES_ROOT_PASSWORD:?Set POSTGRES_ROOT_PASSWORD}@postgres:5432/${databaseName}"`
1169
+ : `\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}"`;
1170
+ const schemaInitService = `\n schema-init:
1171
+ image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
1172
+ command: ["node", "dist/app/cli/applySchema.js"]${schemaInitEnvironment}${schemaInitDependsBlock}
1173
+ networks:
1174
+ - backend
1175
+ restart: "no"`;
1062
1176
  const redisService = options.redis ? `\n redis:
1063
1177
  image: redis:7-alpine
1064
1178
  command:
@@ -1109,7 +1223,7 @@ services:
1109
1223
  stop_grace_period: 2m
1110
1224
  networks:
1111
1225
  - backend
1112
- restart: unless-stopped${workerService}${databaseService}${redisService}
1226
+ restart: unless-stopped${workerService}${schemaInitService}${databaseService}${redisService}
1113
1227
  networks:
1114
1228
  backend:
1115
1229
  driver: bridge
@@ -1124,7 +1238,21 @@ const devWorker = options.worker ? `\n worker:
1124
1238
  command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
1125
1239
  volumes:
1126
1240
  ${devVolumes.join("\n")}${devDependsBlock}` : "";
1127
- const devDatabase = `\n mongodb:
1241
+ const devDatabase = postgres ? `\n postgres:
1242
+ image: postgres:17
1243
+ ports:
1244
+ - "5432:5432"
1245
+ environment:
1246
+ POSTGRES_DB: ${databaseName}
1247
+ POSTGRES_USER: ${options.name}-app
1248
+ POSTGRES_PASSWORD: development
1249
+ volumes:
1250
+ - postgres-dev-data:/var/lib/postgresql/data
1251
+ healthcheck:
1252
+ test: ["CMD-SHELL", "pg_isready -U ${options.name}-app -d ${databaseName}"]
1253
+ interval: 2s
1254
+ timeout: 2s
1255
+ retries: 30` : `\n mongodb:
1128
1256
  image: mongo:8
1129
1257
  command: mongod --replSet rs0 --bind_ip_all
1130
1258
  ports:
@@ -1160,11 +1288,23 @@ const devRedis = options.redis ? `\n redis:
1160
1288
  volumes:
1161
1289
  - redis-dev-data:/data` : "";
1162
1290
  const devNamedVolumes = [
1163
- " mongodb-dev-data:",
1291
+ postgres ? " postgres-dev-data:" : " mongodb-dev-data:",
1164
1292
  ...(options.redis ? [" redis-dev-data:"] : [])
1165
1293
  ];
1166
1294
  add(`${svc}/docker-compose.dev.yml`, `name: ${options.name}-dev
1167
1295
  services:
1296
+ schema-init:
1297
+ build:
1298
+ context: .
1299
+ dockerfile: Dockerfile.dev
1300
+ env_file:
1301
+ - .env.development
1302
+ command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
1303
+ volumes:
1304
+ ${devVolumes.join("\n")}
1305
+ depends_on:
1306
+ ${postgres ? "postgres" : "mongo-init"}:
1307
+ condition: ${postgres ? "service_healthy" : "service_completed_successfully"}
1168
1308
  ${options.name}:
1169
1309
  build:
1170
1310
  context: .
@@ -1179,6 +1319,16 @@ volumes:
1179
1319
  ${devNamedVolumes.join("\n")}
1180
1320
  `);
1181
1321
 
1322
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "service", profiles: serviceProfiles }))) {
1323
+ add(`${svc}/${relative}`, content);
1324
+ }
1325
+ if (options.ui) {
1326
+ const uiProfiles = [...(options.auth === "firebase" ? ["firebase"] : []), "notifications"];
1327
+ for (const [relative, content] of Object.entries(ownedSources({ kind: "ui", profiles: uiProfiles }))) {
1328
+ add(`${ui}/${relative}`, content);
1329
+ }
1330
+ }
1331
+
1182
1332
  for (const [relative, content] of Object.entries(files)) {
1183
1333
  const destination = path.join(root, relative);
1184
1334
  fs.mkdirSync(path.dirname(destination), { recursive: true });
@@ -1186,5 +1336,10 @@ for (const [relative, content] of Object.entries(files)) {
1186
1336
  if (relative.endsWith(".sh")) fs.chmodSync(destination, 0o755);
1187
1337
  }
1188
1338
 
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 };
1339
+ for (const repository of [svc, ...(options.ui ? [ui] : [])]) {
1340
+ const result = runSync(path.join(root, repository), "write", contractVersion);
1341
+ if (result.errors.length) fail(result.errors.join("\n"));
1342
+ }
1343
+
1344
+ return { root, name: options.name, database: options.db, authentication: options.auth, ui: options.ui, redis: options.redis, worker: options.worker, repositories: [svc, ...(options.ui ? [ui] : [])], files: Object.keys(files).length };
1190
1345
  }
@@ -0,0 +1,15 @@
1
+ export const OWNED_GUIDANCE_START = "<!-- tailframe:owned:start -->";
2
+ export const OWNED_GUIDANCE_END = "<!-- tailframe:owned:end -->";
3
+
4
+ export function ownedGuidance(config) {
5
+ const profileList = config.profiles.length ? config.profiles.join(", ") : "none";
6
+ const client = config.kind !== "service";
7
+ return `${OWNED_GUIDANCE_START}
8
+ ## Tailframe-owned contract
9
+
10
+ - Contract: \`${config.contractVersion}\`; kind: \`${config.kind}\`; profiles: ${profileList}.
11
+ - Run \`npm run sync:architecture\` and \`npm run validate:architecture\` after architectural changes.
12
+ - Files written by \`tailframe sync --write\` are generated sources. Change their canonical templates in Tailframe, not in this product.
13
+ - Keep product capabilities under ${client ? "`src/modules/<module>` (or `lib/modules/<module>` for Flutter)" : "`src/modules/<module>`"}; keep application assembly, technology-neutral contracts, and provider adapters in their canonical app/core/platform roots.
14
+ ${client ? "- Notifications use the Tailframe-owned application-shell queue and host. Product copy remains in the owning capability module.\n" : "- Every use case exposes `execute(context, input)`. Entry points construct a request or system context and pass an explicit input object.\n- Resident processes perform no DDL. `schema:apply` is the only schema lifecycle entry point.\n- RPC operations use `<owning-module>.<operation>` with no compatibility aliases.\n"}${OWNED_GUIDANCE_END}`;
15
+ }