@starci/hfs 4.10.0 → 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 (41) hide show
  1. package/CHANGELOG.md +18 -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 +28 -4
  8. package/runtime/knowledge/hfs/slots.yaml +53 -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/architecture/entity-registry.mjs +53 -0
  16. package/runtime/scripts/hfs/architecture/owners.mjs +11 -0
  17. package/runtime/scripts/hfs/architecture/tiers.mjs +14 -3
  18. package/runtime/scripts/hfs/db-baseline-verb.mjs +45 -13
  19. package/runtime/scripts/hfs/manifest-shape.mjs +5 -1
  20. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  21. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
  22. package/runtime/scripts/lib/swarm-stack.mjs +4 -6
  23. package/scaffold/add-queue-lite.mjs +24 -5
  24. package/stack/build.mjs +3 -1
  25. package/sync/index.mjs +19 -7
  26. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -15
  27. package/templates/app/db-image/db/Dockerfile +8 -0
  28. package/templates/app/package-scripts/db.json +2 -1
  29. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
  30. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  31. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
  32. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  33. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
  34. package/templates/app/skeleton-lite/README.md +2 -2
  35. package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
  36. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
  37. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
  38. package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
  39. package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
  40. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
  41. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +3 -2
@@ -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;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The entity list of the primary connection: each capability's `<c>Entities` array (its `persistence/entities/index.ts`), by
3
- * name, no glob. Only an app's composition root imports this file, from the deep path `@modules/platform/database/entities`;
4
- * DatabaseModule and the database barrel never do, since the capability barrels import the database capability back.
3
+ * name, no glob (the entity registry, slot be.platform.database.entities). Only app composition and the test world import this
4
+ * file, from the deep path `@modules/platform/database/entities`; DatabaseModule and the database barrel never do, since the
5
+ * capability barrels import the database capability back.
5
6
  */
6
7
  export const PRIMARY_ENTITIES: ReadonlyArray<Function> = []