@starci/hfs 4.10.1 → 4.11.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/db/drift.mjs +16 -15
  3. package/db/image.mjs +8 -0
  4. package/db/postgres.mjs +1 -1
  5. package/package.json +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  7. package/runtime/knowledge/hfs/rules.yaml +26 -2
  8. package/runtime/knowledge/hfs/slots.yaml +32 -12
  9. package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
  10. package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
  11. package/runtime/knowledge/patterns/be/queues.yaml +12 -11
  12. package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +11 -1
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +1 -1
  15. package/runtime/scripts/hfs/db-baseline-verb.mjs +45 -13
  16. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  17. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
  18. package/runtime/scripts/lib/swarm-stack.mjs +4 -6
  19. package/scaffold/add-queue-lite.mjs +24 -5
  20. package/stack/build.mjs +3 -1
  21. package/sync/index.mjs +19 -7
  22. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -15
  23. package/templates/app/db-image/db/Dockerfile +8 -0
  24. package/templates/app/package-scripts/db.json +2 -1
  25. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
  26. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  27. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
  28. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  29. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
  30. package/templates/app/skeleton-lite/README.md +2 -2
  31. package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
  32. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
  33. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
  34. package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
  35. package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
  36. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
@@ -1,10 +1,9 @@
1
- // R276: lite runs exactly its own PostgreSQL (the db component, image supabase/postgres) with pgvector, admits only the backing services available to its edition,
1
+ // R276: lite runs exactly its own PostgreSQL (the db component, the image its managed db/Dockerfile builds) with pgvector, admits only the backing services available to its edition,
2
2
  // and runs a declared Redis as one service with AOF fsynced at least every second on a named volume and no eviction
3
3
  // (ruling:R212/case-10, owner:lite-vector-is-pgvector); sidecar SQLite persists on declared named volumes.
4
4
  import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
5
5
  import { composeEntries } from '../../lib/compose-values.mjs';
6
6
  import { repositoryOf } from '../../lib/stack-services.mjs';
7
- import { isLiteDatabaseImage } from '../../lib/swarm-stack.mjs';
8
7
  import { loadSlotManifest } from '../slots.mjs';
9
8
  import { judgedInEdition } from '../edition-slots.mjs';
10
9
  import { readJson } from './read.mjs';
@@ -44,9 +43,9 @@ function imageIssues(components, services, { catalog, declared }) {
44
43
  for (const [id, entry] of Object.entries(inventory)) {
45
44
  if (!present(entry?.image)) continue;
46
45
  const service = serviceOfImage(catalog, entry.image);
47
- if (DATABASE_IMAGE.test(repositoryOf(entry.image)) && !isLiteDatabaseImage(entry.image))
46
+ if (DATABASE_IMAGE.test(repositoryOf(entry.image)))
48
47
  errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
49
- message: 'lite runs one database, the db component of image supabase/postgres; no other Postgres or Mongo image' });
48
+ message: 'lite runs one database, the db component of the image db/Dockerfile builds (the official postgres image plus pgvector); no other Postgres or Mongo image' });
50
49
  else if (service !== null && !judgedInEdition(catalog[service], 'lite'))
51
50
  errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
52
51
  message: `lite must not run ${service}; vector search uses pgvector in the db service (owner:lite-vector-is-pgvector)` });
@@ -11,8 +11,6 @@ const BACKING_IMAGE_BROKERS_AND_OTHERS = '(?:cp-)?kafka(?:-native)?|keycloak|min
11
11
  const BACKING_IMAGE = new RegExp(`^(?:${BACKING_IMAGE_SQL_AND_CACHE}|${BACKING_IMAGE_BROKERS_AND_OTHERS})$`, 'i');
12
12
  const mapping = value => plain(value) ? value : {};
13
13
 
14
- /** True for the one database image a lite app runs (its db component): supabase/postgres, pinned by digest. */
15
- export const isLiteDatabaseImage = image => typeof image === 'string' && /^supabase\/postgres(?::[^@]+)?@sha256:[a-f0-9]{64}$/i.test(image);
16
14
  const present = value => typeof value === 'string' && Boolean(value.trim());
17
15
  const configured = value => Object.keys(mapping(value)).length > 0;
18
16
  const schedulerImage = image => present(image) && /(?:^|\/)swarm-cronjob(?:[:@]|$)/.test(image);
@@ -52,8 +50,8 @@ export function swarmInventoryIssues({ components, lite = false }) {
52
50
  const add = (at, message) => errors.push({ at, message });
53
51
  for (const [id, entry] of Object.entries(catalog)) {
54
52
  const component = mapping(entry);
55
- if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image)) && !isLiteDatabaseImage(component.image))
56
- add(`components.${id}.image`, 'lite runs no backing image but its db component (supabase/postgres); storage is the external S3 endpoint, identity the API');
53
+ if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image)))
54
+ add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
57
55
  jobDeclarationIssues(id, component, catalog, add);
58
56
  }
59
57
  return errors;
@@ -120,8 +118,8 @@ export function swarmModelIssues({ model, components, bindings, lite = false })
120
118
  add(`services.${id}.ports`, 'Swarm ports must be an array of published port definitions');
121
119
  else if (service.ports?.length && component.role !== 'ingress')
122
120
  add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
123
- if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image)) && !isLiteDatabaseImage(service.image))
124
- add(`services.${id}.image`, 'lite stack runs no backing image but its db service (supabase/postgres)');
121
+ if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image)))
122
+ add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image db/Dockerfile builds');
125
123
  jobServiceIssues(id, service, component, services, bound, add);
126
124
  }
127
125
  for (const id of Object.keys(bound)) {
@@ -39,8 +39,23 @@ const LITE_TEMPLATES = Object.freeze({
39
39
  'jobs/platform/jobs.index.ts.tpl': 'jobs/platform-lite/jobs.index.ts.tpl',
40
40
  'jobs/platform/job-claim.service.ts.tpl': 'jobs/platform-lite/job-claim.service.ts.tpl',
41
41
  'jobs/platform/job-claim.service.spec.ts.tpl': 'jobs/platform-lite/job-claim.service.spec.ts.tpl',
42
+ // The jobs module composes the one tick scheduler (R335, ruling:lite-official-postgres-image): ScheduleModule.forRoot() and JobTickService.
43
+ 'jobs/platform/jobs.module.ts.tpl': 'jobs/platform-lite/jobs.module.ts.tpl',
42
44
  });
43
45
 
46
+ /** The tick scheduler of platform/jobs the lite jobs capability adds beside full's tree: the ticks as data, the scheduler and its spec (R335). */
47
+ const TICK_FILES = Object.freeze([
48
+ ['be/src/modules/platform/jobs/job-ticks.contracts.ts', 'jobs/platform-lite/job-ticks.contracts.ts.tpl'],
49
+ ['be/src/modules/platform/jobs/job-tick.service.ts', 'jobs/platform-lite/job-tick.service.ts.tpl'],
50
+ ['be/src/modules/platform/jobs/job-tick.service.spec.ts', 'jobs/platform-lite/job-tick.service.spec.ts.tpl'],
51
+ ]);
52
+
53
+ /** The tick files of a jobs capability the verb adds for the first time; an app that already has platform/jobs keeps its own. */
54
+ function tickFiles({ beRoot, patterns }) {
55
+ if (!patterns.includes('fenced-job') || fs.existsSync(path.join(beRoot, 'src', 'modules', 'platform', 'jobs', 'index.ts'))) return [];
56
+ return TICK_FILES.map(([relative, source]) => ({ relative, text: template(`be/patterns/${source}`) }));
57
+ }
58
+
44
59
  /**
45
60
  * The persistence files lite leaves out: the TypeORM schema (its authority is db/migrations) and the SQL text and row files (the
46
61
  * statements are database functions of the lite migration, called through callDbFunction; R334 and the be.persistence lite overlay).
@@ -102,20 +117,24 @@ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
102
117
 
103
118
  /** The queue imports bullmq, which loads ioredis as an optional peer and cannot connect without it. */
104
119
  const QUEUE_CLIENTS = Object.freeze(['bullmq', 'ioredis']);
120
+ /** The jobs capability's tick scheduler imports @nestjs/schedule and its CronJob (R335). */
121
+ const SCHEDULER_CLIENTS = Object.freeze(['@nestjs/schedule', 'cron']);
105
122
 
106
123
  /**
107
- * hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients at their canon pins, and the managed
124
+ * hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients (and, for fenced-job, the tick
125
+ * scheduler's) at their canon pins, and the managed
108
126
  * development scripts of the declared Redis that `starci app sync` renders for it. Each is added when it is missing, so a Redis an
109
127
  * earlier capability declared (a cache on ioredis) still gains bullmq; a file already complete is left alone.
110
128
  */
111
- function declarationPatches(repoRoot, project) {
129
+ function declarationPatches(repoRoot, project, patterns) {
112
130
  const file = path.join(repoRoot, 'hfs.json');
113
131
  const declaration = JSON.parse(read(file));
114
132
  declaration.sides.be.backing = [...new Set([...(declaration.sides.be.backing ?? []), 'redis'])].sort(byCodeUnit);
115
133
  const packageFile = path.join(repoRoot, 'package.json');
116
134
  const pkg = JSON.parse(read(packageFile));
117
135
  const pins = bundledPins();
118
- const missing = QUEUE_CLIENTS.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
136
+ const clients = [...QUEUE_CLIENTS, ...(patterns.includes('fenced-job') ? SCHEDULER_CLIENTS : [])];
137
+ const missing = clients.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
119
138
  for (const name of missing) if (!pins[name]) throw new ScaffoldError('HFS_SCAFFOLD_PIN_MISSING', `${name} has no canon pin in knowledge/hfs/canon-pins.yaml`);
120
139
  if (missing.length)
121
140
  pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, ...Object.fromEntries(missing.map((name) => [name, pins[name].version])) }).sort(([a], [b]) => a.localeCompare(b)));
@@ -331,9 +350,9 @@ export function planLiteQueue({ repoRoot, repo, noun, patterns, now }) {
331
350
  const beRoot = path.join(repoRoot, 'be');
332
351
  const migrations = migrationFiles({ repoRoot, beRoot, patterns, now });
333
352
  const database = skeletonFiles(repoRoot);
334
- const declaration = declarationPatches(repoRoot, repo.project);
353
+ const declaration = declarationPatches(repoRoot, repo.project, patterns);
335
354
  const stack = stackFiles(repoRoot, { project: repo.project, consumers: queueServicesOf(repo) });
336
- const created = [...migrations, ...database.created, ...stack.created];
355
+ const created = [...migrations, ...tickFiles({ beRoot, patterns }), ...database.created, ...stack.created];
337
356
  const patches = [...database.patches, ...declaration, ...stack.patches];
338
357
  const clashes = created.filter((entry) => fs.existsSync(path.join(repoRoot, ...entry.relative.split('/')))).map((entry) => entry.relative);
339
358
  if (clashes.length) throw new ScaffoldError('HFS_ADD_EXISTS', `add ${noun} would overwrite ${clashes.join(', ')}`);
package/stack/build.mjs CHANGED
@@ -13,6 +13,7 @@ import { buildArgs } from './build-args.mjs';
13
13
  import { firstLine, lines as splitLines, tool } from './context.mjs';
14
14
  import { failure, refusal } from './errors.mjs';
15
15
  import { requireAgeIdentity } from './sealed.mjs';
16
+ import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
16
17
  import { scanImage } from './image-scan.mjs';
17
18
 
18
19
  const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
@@ -44,8 +45,9 @@ function ownerOf(ctx, config) {
44
45
  return match[1].toLowerCase();
45
46
  }
46
47
 
47
- /** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile; ambiguity and absence are refusals. */
48
+ /** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or db/Dockerfile for the db image; ambiguity and absence are refusals. */
48
49
  function dockerfileOf(config, app) {
50
+ if (app === DB_IMAGE_APP && fs.existsSync(path.join(config.repoRoot, DB_DOCKERFILE))) return DB_DOCKERFILE;
49
51
  const found = SIDES.map((side) => `${side}/apps/${app}/Dockerfile`).filter((file) => fs.existsSync(path.join(config.repoRoot, file)));
50
52
  if (found.length === 0) throw refusal('STACK_BUILD_NO_DOCKERFILE', `no Dockerfile for app ${app} (be/apps/${app}/Dockerfile or fe/apps/${app}/Dockerfile)`);
51
53
  if (found.length > 1) throw refusal('STACK_BUILD_AMBIGUOUS', `app ${app} has a Dockerfile on both sides: ${found.join(', ')}`);
package/sync/index.mjs CHANGED
@@ -30,6 +30,7 @@ import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs
30
30
  import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
31
31
  import { readDeclaredSonarKey } from './sonar-key.mjs';
32
32
  import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
33
+ import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
33
34
  import { byCodeUnit } from '../report/order.mjs';
34
35
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
35
36
  import { ciWorkflowCommands } from './ci-commands.mjs';
@@ -156,9 +157,16 @@ function trailingNewline(text) {
156
157
  return text.slice(0, end) + '\n';
157
158
  }
158
159
 
160
+ /**
161
+ * The base image of a lite app's managed db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
162
+ * Docker Official postgres image by digest, to which the Dockerfile adds only PGVECTOR_PACKAGE from PGDG.
163
+ */
164
+ export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
165
+ /** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
166
+ export const PGVECTOR_PACKAGE = 'postgresql-17-pgvector=0.8.7-1.pgdg13+1';
167
+ /** The local tag the development db container and the drift scratch database run, built from the app's db/Dockerfile. */
168
+ export const dbLocalImage = project => `${project}-db:local`;
159
169
  /** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
160
- /** The pinned image of a lite app's db service (contexts/stack/pins.yaml images): PostgreSQL with pg_cron, pg_net, pgmq and pgvector. */
161
- export const DB_IMAGE = 'supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2';
162
170
  export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
163
171
 
164
172
  const redisDevText = project => render(readBundled('app/package-scripts/redis-dev.json'), { project, redisImage: REDIS_IMAGE });
@@ -322,17 +330,19 @@ function imageEntries(app, source) {
322
330
  }
323
331
 
324
332
  /**
325
- * The build matrix of the images workflow: one entry per declared app (be then fe), each with the image name and its Dockerfile. The workflow
333
+ * The build matrix of the images workflow: one entry per declared app (be then fe, then a lite app's db image), each with the image name and its Dockerfile. The workflow
326
334
  * runs on the release tag, so every image is built there; no path filter narrows it. A YAML block mapping per entry, indented for
327
335
  * `strategy.matrix.include`.
328
336
  */
329
337
  export function imageMatrix(app, source) {
330
338
  const entry = (side, item) => ` - name: ${item.name}\n file: ${dockerfilePath(side, item.name)}`;
331
- return imageEntries(app, source).map(image => entry(image.side, image.entry)).join('\n');
339
+ const db = app.edition === 'lite' ? [` - name: ${DB_IMAGE_APP}\n file: ${DB_DOCKERFILE}`] : [];
340
+ return [...imageEntries(app, source).map(image => entry(image.side, image.entry)), ...db].join('\n');
332
341
  }
333
342
 
334
- /** The Dockerfile of the app a deploy image names (be first, then fe); an app hfs.json does not declare cannot be built by the managed workflow. */
343
+ /** The Dockerfile of the app a deploy image names (be first, then fe, then a lite app's db image); an app hfs.json does not declare cannot be built by the managed workflow. */
335
344
  function stackDockerfile(app, name) {
345
+ if (app.edition === 'lite' && name === DB_IMAGE_APP) return DB_DOCKERFILE;
336
346
  const side = ['be', 'fe'].find(which => app.sides[which].apps.some(entry => entry.name === name));
337
347
  if (!side) throw new SyncError('HFS_SYNC_STACK_IMAGE', `deploy.images names the app ${name}, which hfs.json does not declare under be or fe`);
338
348
  return dockerfilePath(side, name);
@@ -375,8 +385,10 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
375
385
  project: app.project,
376
386
  appBinary: APP_BINARY,
377
387
  appScripts: appScripts(app, source),
378
- // A lite app's own database runs in development as one container of the pinned db image (ruling:lite-self-hosted-postgres-passport-minio).
379
- dbImage: DB_IMAGE,
388
+ // A lite app's own database runs in development as one container of the image its db/Dockerfile builds (ruling:lite-official-postgres-image).
389
+ dbImage: dbLocalImage(app.project),
390
+ dbBaseImage: DB_BASE_IMAGE,
391
+ pgvectorPackage: PGVECTOR_PACKAGE,
380
392
  // A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
381
393
  backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
382
394
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
@@ -1,16 +1,16 @@
1
1
  -- Managed by starci app sync (slot app.db.bootstrap); do not edit: run "npx starci app sync --write".
2
- -- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio). Migrations written
3
- -- for Supabase reference objects GoTrue and Supabase Storage created: the browser roles, the `auth` schema (auth.users, auth.identities,
4
- -- auth.sessions, auth.uid/jwt/role) and the `storage` schema (storage.buckets, storage.objects). The supabase/postgres image ships only a partial
5
- -- copy of them, and a lite app runs neither GoTrue nor storage-api: auth is the app's own (integrations/passport) and storage is the S3 endpoint
6
- -- of integrations/object-storage. This file gives the migrations those objects, EMPTY, and nothing else. It is idempotent; `starci app db migrate`
2
+ -- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio), run on the official
3
+ -- postgres image of db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
4
+ -- Storage created: the browser roles, the `auth` schema (auth.users, auth.identities, auth.sessions, auth.uid/jwt/role) and the `storage`
5
+ -- schema (storage.buckets, storage.objects). A lite app runs neither GoTrue nor storage-api: auth is the app's own (integrations/passport) and
6
+ -- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and nothing else; nothing
7
+ -- else of Supabase (supabase_admin, the `extensions` schema, the realtime publication) exists. It is idempotent; `starci app db migrate`
7
8
  -- applies it as the database owner before the first migration of an empty database.
8
9
  --
9
10
  -- auth.users keeps the GoTrue column set so restored rows and foreign keys from public tables stay valid; encrypted_password holds bcrypt hashes.
10
11
  -- auth.uid(), auth.role() and auth.jwt() read the request.jwt.claims setting; the API connects as app_be and sets none, so they return NULL and
11
12
  -- every policy that names them denies the browser roles, which have no door in lite.
12
13
 
13
- create schema if not exists extensions;
14
14
  create schema if not exists auth;
15
15
  create schema if not exists storage;
16
16
 
@@ -23,9 +23,8 @@ end
23
23
  $roles$;
24
24
 
25
25
  grant usage on schema auth, storage to anon, authenticated, service_role;
26
- grant usage on schema extensions to anon, authenticated, service_role;
27
26
 
28
- -- auth.users: the full GoTrue column set (the image's old copy is extended in place).
27
+ -- auth.users: the full GoTrue column set (a copy an earlier database carries is extended in place).
29
28
  create table if not exists auth.users (id uuid primary key);
30
29
  alter table auth.users
31
30
  add column if not exists instance_id uuid,
@@ -130,10 +129,3 @@ alter table storage.buckets enable row level security;
130
129
 
131
130
  grant select, insert, update, delete on storage.objects, storage.buckets to authenticated, service_role;
132
131
  grant select on storage.objects, storage.buckets to anon;
133
-
134
- -- Replication publication the baseline adds tables to (the image ships it; a bare PostgreSQL does not).
135
- do $pub$
136
- begin
137
- if not exists (select 1 from pg_publication where pubname = 'supabase_realtime') then create publication supabase_realtime; end if;
138
- end
139
- $pub$;
@@ -0,0 +1,8 @@
1
+ # Managed by starci app sync (slot app.db.image); do not edit: run "npx starci app sync --write".
2
+ # The image of the db service of a lite app (ruling lite-official-postgres-image): the Docker Official postgres image, pinned by
3
+ # digest, plus pgvector from the PGDG repository the image already configures. The contrib extensions (pgcrypto, pg_trgm, unaccent,
4
+ # uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create` and `starci app db drift` all build this file.
5
+ FROM {{dbBaseImage}}
6
+ RUN apt-get update \
7
+ && apt-get install -y --no-install-recommends {{pgvectorPackage}} \
8
+ && rm -rf /var/lib/apt/lists/*
@@ -1,4 +1,5 @@
1
- "db:create": "docker create --name {{project}}-dev-db -p 127.0.0.1:54322:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -v {{project}}-dev-db-data:/var/lib/postgresql/data {{dbImage}}",
1
+ "db:image": "docker build --tag {{dbImage}} db",
2
+ "db:create": "npm run db:image && docker create --name {{project}}-dev-db -p 127.0.0.1:54322:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -v {{project}}-dev-db-data:/var/lib/postgresql/data {{dbImage}}",
2
3
  "db:start": "docker start {{project}}-dev-db",
3
4
  "db:stop": "docker stop {{project}}-dev-db",
4
5
  "db:reset": "docker rm -f {{project}}-dev-db && docker volume rm {{project}}-dev-db-data && npm run db:create && npm run db:start",
@@ -27,9 +27,9 @@ components:
27
27
  role: service
28
28
  stateful: true
29
29
  volumes: [db-data]
30
- image: "supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2"
30
+ image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
31
31
  compose: infra/swarm/stack.yaml
32
- purpose: The app's own PostgreSQL (pg_cron, pg_net, pgmq, vector), migrated by starci app db migrate.
32
+ purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from db/Dockerfile, migrated by starci app db migrate.
33
33
  required: true
34
34
  ingress:
35
35
  role: ingress
@@ -203,6 +203,8 @@ environments:
203
203
  images:
204
204
  api:
205
205
  app: api
206
+ db:
207
+ app: db
206
208
  web:
207
209
  app: web
208
210
  # Public browser configuration; replace these placeholders before deployment.
@@ -1,6 +1,6 @@
1
1
  # Development stack
2
2
 
3
- Every development dependency runs in Docker Linux containers. The `db` service (image supabase/postgres: PostgreSQL with pg_cron, pg_net, pgmq and pgvector) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
3
+ Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `db/Dockerfile`) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
4
4
  The application image references contain illustrative all-zero digests. Replace them with verified published release digests in the manifest, model and `.starcistacks/dev/infra/swarm/releases/images.lock.yaml` before starting the container model. Seal the local database connection and the integration credentials; a container cannot use the host's loopback address without owner-configured networking. Local source development remains available through the app scripts.
5
5
 
6
6
  The runtime template tracks no encrypted members. Scaffolding generates the six declared members with placeholder values encrypted to this app's own recipient in `.sops.yaml`. Replace those values with `starci app secret set dev` using the matching host age identity in untracked local custody. Existing generated envelopes are decryptable with that app identity and can be updated in place. Each Docker secret contains the corresponding key's real value, and the API receives it through its declared mounted file.
@@ -83,7 +83,7 @@ services:
83
83
  condition: on-failure
84
84
  delay: 5s
85
85
  db:
86
- image: "supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2"
86
+ image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
87
87
  environment:
88
88
  POSTGRES_DB: postgres
89
89
  POSTGRES_PASSWORD_FILE: /run/secrets/db-password
@@ -1,7 +1,7 @@
1
1
  # Production stack
2
2
 
3
3
  The tracked model declares Docker Swarm application services and one digest-pinned Caddy ingress.
4
- The `db` service (image supabase/postgres) supplies PostgreSQL with pg_cron, pg_net, pgmq and pgvector on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
4
+ The `db` service (the official postgres image plus pgvector, built from `db/Dockerfile` and pushed with the release images) supplies PostgreSQL on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
5
5
  The model is a deployment declaration. It contains no owner credentials, real DNS receipt, completed release or restore proof. Public records under `.starcistacks/production/infra/swarm/` explicitly mark those owner-run operations as pending.
6
6
  Its application image references contain illustrative all-zero digests; they identify no published image and must be replaced before deployment.
7
7
 
@@ -83,7 +83,7 @@ services:
83
83
  condition: on-failure
84
84
  delay: 5s
85
85
  db:
86
- image: "supabase/postgres:17.11.0.006@sha256:c141fd17ac6a76c4323e90b3aee8ff1a23d2e89e547c2b963cfadc309c079cc2"
86
+ image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
87
87
  environment:
88
88
  POSTGRES_DB: postgres
89
89
  POSTGRES_PASSWORD_FILE: /run/secrets/db-password
@@ -8,7 +8,7 @@ The starter keeps one deployable API and one web app, with the same code structu
8
8
 
9
9
  ## Stack
10
10
 
11
- TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service, image supabase/postgres), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
11
+ TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from `db/Dockerfile`), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
12
12
 
13
13
  ## Repository layout
14
14
 
@@ -24,7 +24,7 @@ Declare `ci.github.repository` and `ci.github.defaultBranch` in `.starcistacks/a
24
24
 
25
25
  ## Development
26
26
 
27
- Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the pinned `db` image, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://supabase_admin@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
27
+ Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image `db/Dockerfile` builds, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://postgres@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
28
28
 
29
29
  ```sh
30
30
  npm install
@@ -6,4 +6,6 @@ export enum JobsLogEvent {
6
6
  DeliveryFailed = "jobs.delivery.failed",
7
7
  /** A failed delivery could not even be recorded as failed; the lease will expire and the job will be claimed again. */
8
8
  FailureNotRecorded = "jobs.delivery.failure-not-recorded",
9
+ /** A scheduler tick failed to fire; the next firing claims its own minute (lite, R335). */
10
+ TickFailed = "jobs.tick.failed",
9
11
  }
@@ -0,0 +1,65 @@
1
+ import { SchedulerRegistry } from "@nestjs/schedule"
2
+ import { Test } from "@nestjs/testing"
3
+ import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
4
+ import { CLOCK } from "@modules/platform/clock"
5
+ import { LOGGER } from "@modules/platform/logging"
6
+ import { QUEUE_OUTBOX } from "@modules/platform/queue"
7
+ import type { JobTick } from "./job-ticks.contracts"
8
+ import { JOBS_MANAGERS } from "./jobs.decorators"
9
+ import { JobTickService, tickMinute } from "./job-tick.service"
10
+
11
+ // The statements advisoryXactLock and callDbFunction of platform/database send for one firing.
12
+ const LOCK_TICK = "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))"
13
+ const CLAIM_TICK = "SELECT * FROM job_tick_claim($1, $2)"
14
+
15
+ const AT = "2026-02-03T04:05:06.000Z"
16
+ const TICK: JobTick = { name: "nightly-digest", cron: "0 2 * * *", timeZone: "UTC", queue: "digests", payload: { kind: "nightly" } }
17
+
18
+ const build = async (claimed: boolean) => {
19
+ const transaction = fakeTransaction(mockEntityManager({ query: [[LOCK_TICK, []], [CLAIM_TICK, [{ claimed }]]] }))
20
+ const outbox = { write: jest.fn().mockResolvedValue(undefined) }
21
+ const registry = { addCronJob: jest.fn() }
22
+ const moduleRef = await Test.createTestingModule({
23
+ providers: [
24
+ JobTickService,
25
+ { provide: SchedulerRegistry, useValue: registry },
26
+ { provide: JOBS_MANAGERS, useValue: [transaction.em] },
27
+ { provide: QUEUE_OUTBOX, useValue: outbox },
28
+ { provide: CLOCK, useValue: new FakeClock(AT) },
29
+ { provide: LOGGER, useValue: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } },
30
+ ],
31
+ }).compile()
32
+ return { ticks: moduleRef.get(JobTickService), transaction, outbox, registry }
33
+ }
34
+
35
+ describe("JobTickService", () => {
36
+ it("names a tick's slot by its UTC minute", () => {
37
+ expect(tickMinute(new Date(AT))).toBe("2026-02-03T04:05")
38
+ })
39
+
40
+ it("locks the tick, claims its minute and enqueues one job in the same transaction", async () => {
41
+ const { ticks, transaction, outbox } = await build(true)
42
+
43
+ await expect(ticks.fire(TICK)).resolves.toBe(true)
44
+
45
+ expect(transaction.em.query).toHaveBeenNthCalledWith(1, LOCK_TICK, ["tick:nightly-digest"])
46
+ expect(transaction.em.query).toHaveBeenNthCalledWith(2, CLAIM_TICK, ["nightly-digest", "2026-02-03T04:05"])
47
+ expect(outbox.write).toHaveBeenCalledWith(expect.anything(), "digests", { kind: "nightly" }, "tick:nightly-digest:2026-02-03T04:05")
48
+ })
49
+
50
+ it("enqueues nothing when another replica already fired the tick this minute", async () => {
51
+ const { ticks, outbox } = await build(false)
52
+
53
+ await expect(ticks.fire(TICK)).resolves.toBe(false)
54
+
55
+ expect(outbox.write).not.toHaveBeenCalled()
56
+ })
57
+
58
+ it("registers no cron job while the tick list is empty", async () => {
59
+ const { ticks, registry } = await build(true)
60
+
61
+ ticks.onApplicationBootstrap()
62
+
63
+ expect(registry.addCronJob).not.toHaveBeenCalled()
64
+ })
65
+ })
@@ -0,0 +1,78 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { OnApplicationBootstrap } from "@nestjs/common"
3
+ import { SchedulerRegistry } from "@nestjs/schedule"
4
+ import { CronJob } from "cron"
5
+ import type { EntityManager } from "typeorm"
6
+ import { InjectClock } from "@modules/platform/clock"
7
+ import type { Clock } from "@modules/platform/clock"
8
+ import { advisoryXactLock, callDbFunction } from "@modules/platform/database"
9
+ import { InjectLogger } from "@modules/platform/logging"
10
+ import type { Logger } from "@modules/platform/logging"
11
+ import { InjectQueueOutbox } from "@modules/platform/queue"
12
+ import type { QueueOutbox } from "@modules/platform/queue"
13
+ import { JobsError, JobsErrorCode } from "./errors/jobs.error"
14
+ import { JOB_TICKS } from "./job-ticks.contracts"
15
+ import type { JobTick } from "./job-ticks.contracts"
16
+ import { InjectJobsManagers } from "./jobs.decorators"
17
+ import { JobsLogEvent } from "./jobs.log-events"
18
+
19
+ /** The record job_tick_claim returns. */
20
+ interface TickClaim {
21
+ /** True when this firing moved the tick to its minute and must enqueue. */
22
+ readonly claimed: boolean
23
+ }
24
+
25
+ /** The UTC minute of `at` (`YYYY-MM-DDTHH:MM`): the slot a tick fires once in. */
26
+ export const tickMinute = (at: Date): string => at.toISOString().slice(0, 16)
27
+
28
+ @Injectable()
29
+ /**
30
+ * The one scheduler of the app (R335): @nestjs/schedule fires every tick of JOB_TICKS on every replica, and each firing takes
31
+ * advisoryXactLock("tick:<name>"), claims the tick's minute through job_tick_claim and, only when it moved the tick, writes one
32
+ * job into the queue outbox in the same transaction, so the deployment enqueues the tick once per minute.
33
+ */
34
+ export class JobTickService implements OnApplicationBootstrap {
35
+ constructor(
36
+ private readonly registry: SchedulerRegistry,
37
+ @InjectJobsManagers() private readonly managers: ReadonlyArray<EntityManager>,
38
+ @InjectQueueOutbox() private readonly outbox: QueueOutbox,
39
+ @InjectClock() private readonly clock: Clock,
40
+ @InjectLogger() private readonly logger: Logger,
41
+ ) {}
42
+
43
+ /** Registers one cron job per tick, read in the tick's time zone; a failed firing is logged and the next one runs. */
44
+ onApplicationBootstrap(): void {
45
+ for (const tick of JOB_TICKS) {
46
+ this.registry.addCronJob(tick.name, CronJob.from({
47
+ cronTime: tick.cron,
48
+ timeZone: tick.timeZone,
49
+ onTick: async () => {
50
+ await this.fire(tick)
51
+ },
52
+ errorHandler: (cause: unknown) => {
53
+ this.logger.error(JobsLogEvent.TickFailed, cause, { tick: tick.name })
54
+ },
55
+ start: true,
56
+ }))
57
+ }
58
+ }
59
+
60
+ /** Fires the tick for the current minute; true when this replica enqueued its job, false when another one already had. */
61
+ async fire(tick: JobTick): Promise<boolean> {
62
+ const minute = tickMinute(this.clock.now())
63
+ return this.writer().transaction(async (tx) => {
64
+ await advisoryXactLock(tx, `tick:${tick.name}`)
65
+ const [claim] = await callDbFunction<TickClaim>(tx, "job_tick_claim", [tick.name, minute])
66
+ if (claim?.claimed !== true) return false
67
+ await this.outbox.write(tx, tick.queue, tick.payload, `tick:${tick.name}:${minute}`)
68
+ return true
69
+ })
70
+ }
71
+
72
+ /** The entity manager of the connection that holds the tick table. */
73
+ private writer(): EntityManager {
74
+ const [manager] = this.managers
75
+ if (manager === undefined) throw new JobsError({ code: JobsErrorCode.ConnectionMissing })
76
+ return manager
77
+ }
78
+ }
@@ -0,0 +1,16 @@
1
+ /** One recurring tick of platform/jobs: at each minute its cron expression names, one job of `queue` is enqueued with `payload` (R335). */
2
+ export interface JobTick {
3
+ /** The stable name: the advisory lock key, the minute claim and the correlation id carry it. */
4
+ readonly name: string
5
+ /** The five-field cron expression of the minutes the tick fires at. */
6
+ readonly cron: string
7
+ /** The time zone the expression is read in; ticks run in UTC. */
8
+ readonly timeZone: "UTC"
9
+ /** The queue the tick enqueues one job of. */
10
+ readonly queue: string
11
+ /** The payload of that job: ids and literals only. */
12
+ readonly payload: object
13
+ }
14
+
15
+ /** The ticks of this app, as data: JobTickService registers one cron job per entry. */
16
+ export const JOB_TICKS: ReadonlyArray<JobTick> = []
@@ -0,0 +1,40 @@
1
+ import { Module } from "@nestjs/common"
2
+ import type { DynamicModule } from "@nestjs/common"
3
+ import { ScheduleModule } from "@nestjs/schedule"
4
+ import type { EntityManager } from "typeorm"
5
+ import { JOB_CLAIMS, JOB_PROCESSOR_REGISTRY, JOBS_MANAGERS } from "./jobs.decorators"
6
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./jobs.module-definition"
7
+ import { JobClaimService } from "./job-claim.service"
8
+ import { JobRunnerService } from "./job-runner.service"
9
+ import { JobTickService } from "./job-tick.service"
10
+
11
+ @Module({})
12
+ /**
13
+ * The fenced jobs of a service: the claim service that owns the job row, the runner that subscribes processors to their queues and
14
+ * the one tick scheduler (R335), which composes @nestjs/schedule here and nowhere else. Needs the queue capability registered in
15
+ * the same app.
16
+ */
17
+ export class JobsModule extends ConfigurableModuleClass {
18
+ /** Registers the capability once per app that runs jobs. */
19
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
20
+ const base = super.register(options)
21
+ return {
22
+ ...base,
23
+ imports: [...(base.imports ?? []), ScheduleModule.forRoot()],
24
+ providers: [
25
+ ...(base.providers ?? []),
26
+ {
27
+ provide: JOBS_MANAGERS,
28
+ useFactory: (manager: EntityManager) => [manager],
29
+ inject: [options.connection],
30
+ },
31
+ JobClaimService,
32
+ JobRunnerService,
33
+ JobTickService,
34
+ { provide: JOB_CLAIMS, useExisting: JobClaimService },
35
+ { provide: JOB_PROCESSOR_REGISTRY, useExisting: JobRunnerService },
36
+ ],
37
+ exports: [JOB_CLAIMS, JOB_PROCESSOR_REGISTRY],
38
+ }
39
+ }
40
+ }
@@ -93,3 +93,47 @@ revoke all on function public.job_complete(uuid, bigint, timestamptz) from publi
93
93
  grant execute on function public.job_complete(uuid, bigint, timestamptz) to app_be;
94
94
  revoke all on function public.job_fail(uuid, bigint, text, timestamptz) from public;
95
95
  grant execute on function public.job_fail(uuid, bigint, text, timestamptz) to app_be;
96
+
97
+ -- The scheduler ticks of platform/jobs (R335): one row per tick holds the last UTC minute it fired. Every replica's
98
+ -- @nestjs/schedule fires the tick; under advisoryXactLock('tick:<name>') job_tick_claim moves the row to the new minute and
99
+ -- answers claimed, so exactly one replica enqueues the tick's job per minute and a replica that comes later answers not claimed.
100
+ create table public.job_ticks (
101
+ name varchar(200) not null,
102
+ minute varchar(16) not null,
103
+ constraint job_ticks_pkey primary key (name)
104
+ );
105
+
106
+ alter table public.job_ticks enable row level security;
107
+ alter table public.job_ticks force row level security;
108
+
109
+ create policy job_ticks_app_be_select
110
+ on public.job_ticks for select to app_be
111
+ using (name is not null);
112
+
113
+ create policy job_ticks_app_be_insert
114
+ on public.job_ticks for insert to app_be
115
+ with check (name is not null);
116
+
117
+ create policy job_ticks_app_be_update
118
+ on public.job_ticks for update to app_be
119
+ using (name is not null)
120
+ with check (name is not null);
121
+
122
+ revoke all on table public.job_ticks from public, anon, authenticated;
123
+ grant select, insert, update on table public.job_ticks to app_be;
124
+
125
+ create function public.job_tick_claim(p_name varchar, p_minute varchar)
126
+ returns table (claimed boolean)
127
+ language sql
128
+ as $$
129
+ with moved as (
130
+ insert into public.job_ticks as t (name, minute) values (p_name, p_minute)
131
+ on conflict (name) do update set minute = excluded.minute
132
+ where t.minute < excluded.minute
133
+ returning true as claimed
134
+ )
135
+ select coalesce((select m.claimed from moved as m), false)
136
+ $$;
137
+
138
+ revoke all on function public.job_tick_claim(varchar, varchar) from public;
139
+ grant execute on function public.job_tick_claim(varchar, varchar) to app_be;