@pattern-stack/codegen 0.2.0 → 0.3.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 (52) hide show
  1. package/README.md +9 -4
  2. package/dist/src/cli/index.js +136 -128
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/dist/src/index.d.ts +16 -0
  5. package/dist/src/index.js +25 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/package.json +10 -1
  8. package/templates/entity/new/backend/application/commands/create.ejs.t +38 -1
  9. package/templates/entity/new/backend/application/commands/delete.ejs.t +41 -1
  10. package/templates/entity/new/backend/application/commands/update.ejs.t +42 -1
  11. package/templates/entity/new/backend/database/repository.ejs.t +33 -3
  12. package/templates/entity/new/backend/domain/repository-interface.ejs.t +6 -3
  13. package/templates/entity/new/backend/modules/core/module.ejs.t +6 -0
  14. package/templates/entity/new/backend/presentation/controller.ejs.t +32 -10
  15. package/templates/entity/new/clean-lite-ps/controller.ejs.t +72 -11
  16. package/templates/entity/new/clean-lite-ps/entity.ejs.t +16 -2
  17. package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -1
  18. package/templates/entity/new/clean-lite-ps/module.ejs.t +45 -2
  19. package/templates/entity/new/clean-lite-ps/prompt-extension.js +459 -98
  20. package/templates/entity/new/clean-lite-ps/repository.ejs.t +57 -4
  21. package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +50 -0
  22. package/templates/entity/new/clean-lite-ps/service.ejs.t +98 -1
  23. package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +150 -0
  24. package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +70 -0
  25. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +19 -0
  26. package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +7 -3
  27. package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +17 -0
  28. package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +63 -0
  29. package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +153 -0
  30. package/templates/entity/new/prompt.js +284 -41
  31. package/templates/relationship/new/entity.ejs.t +2 -2
  32. package/templates/relationship/new/prompt.js +3 -7
  33. package/templates/relationship/new/service.ejs.t +1 -1
  34. package/templates/subsystem/bridge/generated-keep.ejs.t +4 -0
  35. package/templates/subsystem/bridge/prompt.js +36 -0
  36. package/templates/subsystem/bridge-config/codegen-config-bridge-block.ejs.t +20 -0
  37. package/templates/subsystem/bridge-config/prompt.js +20 -0
  38. package/templates/subsystem/events/domain-events.schema.ejs.t +81 -0
  39. package/templates/subsystem/events/generated-keep.ejs.t +4 -0
  40. package/templates/subsystem/events/prompt.js +39 -0
  41. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +26 -0
  42. package/templates/subsystem/events-config/prompt.js +20 -0
  43. package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +221 -0
  44. package/templates/subsystem/jobs/main-hook.ejs.t +11 -0
  45. package/templates/subsystem/jobs/prompt.js +40 -0
  46. package/templates/subsystem/jobs/worker.ejs.t +82 -0
  47. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +55 -0
  48. package/templates/subsystem/jobs-config/prompt.js +20 -0
  49. package/templates/subsystem/sync/prompt.js +43 -0
  50. package/templates/subsystem/sync/sync-audit.schema.ejs.t +195 -0
  51. package/templates/subsystem/sync-config/codegen-config-sync-block.ejs.t +29 -0
  52. package/templates/subsystem/sync-config/prompt.js +22 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hygen prompt.js — BRIDGE-9 bridge config-block scaffold.
3
+ *
4
+ * Split from `templates/subsystem/bridge/` so the CLI can invoke the
5
+ * config-block inject step independently. `subsystem install bridge --force`
6
+ * preserves an existing `bridge:` block by skipping this action;
7
+ * `--force-config` opts into regenerating it (mirrors EVT-8 / SYNC-7 +
8
+ * #121 / F13 precedent).
9
+ *
10
+ * Invoked via:
11
+ * bunx hygen subsystem bridge-config --configPath <abs>
12
+ */
13
+
14
+ export default {
15
+ prompt: async ({ args }) => {
16
+ return {
17
+ configPath: args.configPath ?? "codegen.config.yaml",
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,81 @@
1
+ ---
2
+ to: "<%= schemaPath %>"
3
+ force: true
4
+ ---
5
+ /**
6
+ * Drizzle schema for the domain_events outbox table.
7
+ *
8
+ * This table backs the DrizzleEventBus. Events are inserted within the
9
+ * same database transaction as the domain write (outbox pattern). A
10
+ * polling process reads unprocessed rows and dispatches to subscribers.
11
+ *
12
+ * First-class routing columns (EVT-1):
13
+ * - `pool` — populated by DrizzleEventBus.publish() (EVT-4); enables
14
+ * pool-filtered drain queries without unpacking metadata JSON.
15
+ * - `direction` — `inbound` | `change` | `outbound`; mirrors the routing
16
+ * dimension used by jobs' reserved `events_inbound` /
17
+ * `events_change` / `events_outbound` pools.
18
+ * - `tenant_id` — scaffold-time conditional: emitted only when
19
+ * `events.multi_tenant: true` in `codegen.config.yaml`.
20
+ * See EVT-8 and the JOB-6 precedent for the same pattern.
21
+ *
22
+ * The `metadata` JSON column continues to carry these values for protocol
23
+ * stability; the first-class columns are an optimization for drain filtering.
24
+ *
25
+ * Indexes (declared below in the index callback):
26
+ * - (status, occurred_at) — polling drain filter
27
+ * - (aggregate_id, aggregate_type) — event replay per aggregate
28
+ * - (pool, status, occurred_at) — per-pool drain filter (EVT-1)
29
+ */
30
+ import {
31
+ index,
32
+ jsonb,
33
+ pgTable,
34
+ text,
35
+ timestamp,
36
+ uuid,
37
+ } from 'drizzle-orm/pg-core';
38
+ import type { InferSelectModel } from 'drizzle-orm';
39
+
40
+ export const domainEvents = pgTable(
41
+ 'domain_events',
42
+ {
43
+ id: uuid('id').primaryKey(),
44
+ type: text('type').notNull(),
45
+ aggregateId: text('aggregate_id').notNull(),
46
+ aggregateType: text('aggregate_type').notNull(),
47
+ payload: jsonb('payload').notNull().$type<Record<string, unknown>>(),
48
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
49
+ processedAt: timestamp('processed_at', { withTimezone: true }),
50
+ /** Lifecycle status: pending | processed | failed */
51
+ status: text('status').notNull().default('pending'),
52
+ /** Error message from the last failed dispatch attempt. */
53
+ error: text('error'),
54
+ metadata: jsonb('metadata').$type<Record<string, unknown>>(),
55
+ /** Routing pool (e.g. `events_inbound`, `events_change`, `events_outbound`). Populated by DrizzleEventBus.publish() in EVT-4. */
56
+ pool: text('pool'),
57
+ /** Routing direction: `inbound` | `change` | `outbound`. Populated by DrizzleEventBus.publish() in EVT-4. */
58
+ direction: text('direction'),
59
+ <% if (multiTenant) { -%>
60
+ tenantId: text('tenant_id'), // scaffold-time conditional — see EVT-8
61
+ <% } -%>
62
+ },
63
+ (t) => ({
64
+ /** Polling drain filter (existing — promoted from comment to declaration in EVT-1). */
65
+ idxDomainEventsStatusOccurredAt: index('idx_domain_events_status_occurred_at').on(
66
+ t.status,
67
+ t.occurredAt,
68
+ ),
69
+ /** Event replay per aggregate (existing — promoted from comment to declaration in EVT-1). */
70
+ idxDomainEventsAggregate: index('idx_domain_events_aggregate').on(
71
+ t.aggregateId,
72
+ t.aggregateType,
73
+ ),
74
+ /** Per-pool drain filter (EVT-1). Enables DrizzleEventBus to drain a single pool without scanning all events. */
75
+ idxDomainEventsPoolStatusOccurredAt: index(
76
+ 'idx_domain_events_pool_status_occurred_at',
77
+ ).on(t.pool, t.status, t.occurredAt),
78
+ }),
79
+ );
80
+
81
+ export type DomainEventRecord = InferSelectModel<typeof domainEvents>;
@@ -0,0 +1,4 @@
1
+ ---
2
+ to: "<%= generatedKeepPath %>"
3
+ unless_exists: true
4
+ ---
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Hygen prompt.js — EVT-8 events subsystem scaffold.
3
+ *
4
+ * All locals are resolved by the CLI (src/cli/shared/events-scaffold-locals.ts)
5
+ * and forwarded as CLI args. This prompt.js coerces boolean-ish strings back
6
+ * into JS booleans so template `<% if (multiTenant) { %>` gates work — Hygen
7
+ * args arrive as strings, and `if ("false")` would render truthy in EJS.
8
+ *
9
+ * Invoked via:
10
+ * bunx hygen subsystem events \
11
+ * --configPath <abs> --schemaPath <abs> --generatedKeepPath <abs> \
12
+ * --multiTenant <'true'|'false'> --appName <string>
13
+ *
14
+ * Unlike jobs, events has no separate worker process — the outbox drain loop
15
+ * runs inside the NestJS app context wherever `EventsModule.forRoot(...)` is
16
+ * imported. So no workerPath / workerMode / mainTsPath locals here.
17
+ */
18
+
19
+ function coerceBool(raw) {
20
+ if (raw === true) return true;
21
+ if (raw === false) return false;
22
+ if (typeof raw === "string") return raw.toLowerCase() === "true";
23
+ return false;
24
+ }
25
+
26
+ export default {
27
+ prompt: async ({ args }) => {
28
+ return {
29
+ appName: args.appName ?? "",
30
+ multiTenant: coerceBool(args.multiTenant),
31
+ configPath: args.configPath ?? "codegen.config.yaml",
32
+ schemaPath:
33
+ args.schemaPath ?? "shared/subsystems/events/domain-events.schema.ts",
34
+ generatedKeepPath:
35
+ args.generatedKeepPath ??
36
+ "shared/subsystems/events/generated/.gitkeep",
37
+ };
38
+ },
39
+ };
@@ -0,0 +1,26 @@
1
+ ---
2
+ to: "<%= configPath %>"
3
+ inject: true
4
+ append: true
5
+ skip_if: "events:"
6
+ ---
7
+
8
+ events:
9
+ # ── Backend selection (core/extension model — see CLAUDE.md) ──
10
+ # 'drizzle' is the production backend (transactional outbox). 'memory'
11
+ # is the synchronous test backend. Future backends (e.g. 'redis',
12
+ # 'nats') implement the same core IEventBus contract.
13
+ backend: drizzle
14
+
15
+ # ── Multi-tenancy (EVT-6 / ADR-024) ──
16
+ # When true the generated schema gains a `tenant_id` column and
17
+ # `TypedEventBus.publish` throws `MissingTenantIdError` when the caller
18
+ # forgets `metadata.tenantId`. Enabling post-install requires a
19
+ # reinstall (`subsystem install events`) plus an Atlas migration.
20
+ multi_tenant: false
21
+
22
+ # ── Optional drain-loop pool filter ──
23
+ # Restrict this process to specific lanes. Leave commented to drain
24
+ # all pending rows. Typical split is one process per lane so a slow
25
+ # outbound handler cannot stall change-event propagation.
26
+ # pools: [] # e.g. [events_inbound] | [events_change] | [events_outbound]
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hygen prompt.js — #121 (F13) events config-block scaffold.
3
+ *
4
+ * Split from `templates/subsystem/events/` so the CLI can invoke the
5
+ * config-block inject step independently of the rest of the events scaffold.
6
+ * This lets `subsystem install events --force` preserve an existing `events:`
7
+ * block by skipping this action entirely, while `--force-config` opts in
8
+ * to regenerating it.
9
+ *
10
+ * Invoked via:
11
+ * bunx hygen subsystem events-config --configPath <abs>
12
+ */
13
+
14
+ export default {
15
+ prompt: async ({ args }) => {
16
+ return {
17
+ configPath: args.configPath ?? "codegen.config.yaml",
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,221 @@
1
+ ---
2
+ to: "<%= schemaPath %>"
3
+ force: true
4
+ ---
5
+ /**
6
+ * Drizzle schema for the job orchestration domain (ADR-022).
7
+ *
8
+ * Three tables model the lifecycle of a durable job:
9
+ * - `job` — definitions keyed by handler type (e.g. 'onboarding').
10
+ * - `job_run` — one row per attempt to execute a job; worker claims
11
+ * rows directly via SELECT ... FOR UPDATE SKIP LOCKED.
12
+ * - `job_step` — individual steps within a run; memoises output for replay.
13
+ *
14
+ * Phase 1 ships only this layer. There is no `job_queue` table, no executor
15
+ * port — see ADR-022 and `.claude/skills/jobs/SKILL.md` for the rationale.
16
+ */
17
+ import {
18
+ pgEnum,
19
+ pgTable,
20
+ uuid,
21
+ text,
22
+ jsonb,
23
+ integer,
24
+ timestamp,
25
+ index,
26
+ uniqueIndex,
27
+ } from 'drizzle-orm/pg-core';
28
+ import { sql } from 'drizzle-orm';
29
+ import type { InferSelectModel } from 'drizzle-orm';
30
+
31
+ // ─── Internal $type<> helpers ───────────────────────────────────────────────
32
+ // Annotation types for jsonb columns only. JOB-2 defines the public protocol
33
+ // types; these remain private to this file.
34
+
35
+ type RetryPolicy = {
36
+ attempts: number;
37
+ backoff: 'fixed' | 'exponential';
38
+ baseMs: number;
39
+ nonRetryableErrors?: string[];
40
+ };
41
+
42
+ type JobRunError = {
43
+ message: string;
44
+ stack?: string;
45
+ retryable: boolean;
46
+ attempt: number;
47
+ };
48
+
49
+ // ─── Enums ──────────────────────────────────────────────────────────────────
50
+
51
+ export const jobRunStatusEnum = pgEnum('job_run_status', [
52
+ 'pending',
53
+ 'running',
54
+ 'waiting',
55
+ 'completed',
56
+ 'failed',
57
+ 'timed_out',
58
+ 'canceled',
59
+ ]);
60
+
61
+ // extended in ADR-027: tool_call | llm_call | wait | checkpoint | message
62
+ export const jobStepKindEnum = pgEnum('job_step_kind', ['task']);
63
+
64
+ export const jobStepStatusEnum = pgEnum('job_step_status', [
65
+ 'pending',
66
+ 'running',
67
+ 'completed',
68
+ 'failed',
69
+ 'skipped',
70
+ ]);
71
+
72
+ export const collisionModeEnum = pgEnum('job_collision_mode', [
73
+ 'queue',
74
+ 'reject',
75
+ 'replace',
76
+ ]);
77
+
78
+ export const replayFromEnum = pgEnum('job_replay_from', [
79
+ 'scratch',
80
+ 'last_step',
81
+ 'last_checkpoint',
82
+ ]);
83
+
84
+ export const parentClosePolicyEnum = pgEnum('job_parent_close_policy', [
85
+ 'terminate',
86
+ 'cancel',
87
+ 'abandon',
88
+ ]);
89
+
90
+ // Phase 3 placeholder — see ADR-025
91
+ export const waitKindEnum = pgEnum('job_wait_kind', ['signal']);
92
+
93
+ // Phase 2 may add more sources; requires Atlas migration
94
+ export const triggerSourceEnum = pgEnum('job_trigger_source', [
95
+ 'manual',
96
+ 'schedule',
97
+ 'event',
98
+ 'parent',
99
+ ]);
100
+
101
+ // ─── job ────────────────────────────────────────────────────────────────────
102
+
103
+ export const jobs = pgTable('job', {
104
+ type: text('type').primaryKey(),
105
+ version: integer('version').notNull().default(1),
106
+ pool: text('pool').notNull(),
107
+ scopeEntityType: text('scope_entity_type'),
108
+ retryPolicy: jsonb('retry_policy').notNull().$type<RetryPolicy>(),
109
+ timeoutMs: integer('timeout_ms'),
110
+ concurrencyKeyTemplate: text('concurrency_key_template'),
111
+ collisionMode: collisionModeEnum('collision_mode').notNull().default('queue'),
112
+ dedupeKeyTemplate: text('dedupe_key_template'),
113
+ dedupeWindowMs: integer('dedupe_window_ms'),
114
+ priorityDefault: integer('priority_default').notNull().default(0),
115
+ replayFrom: replayFromEnum('replay_from').notNull().default('last_checkpoint'),
116
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
117
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
118
+ });
119
+
120
+ export type JobDefinitionRow = InferSelectModel<typeof jobs>;
121
+
122
+ // ─── job_run ────────────────────────────────────────────────────────────────
123
+
124
+ export const jobRuns = pgTable(
125
+ 'job_run',
126
+ {
127
+ id: uuid('id').primaryKey().defaultRandom(),
128
+ jobType: text('job_type').notNull().references(() => jobs.type),
129
+ jobVersion: integer('job_version').notNull(),
130
+ parentRunId: uuid('parent_run_id').references((): any => jobRuns.id),
131
+ /**
132
+ * Service generates `id` client-side via randomUUID() and sets
133
+ * root_run_id = id for root runs (single INSERT, no self-FK race).
134
+ */
135
+ rootRunId: uuid('root_run_id').notNull(),
136
+ parentClosePolicy: parentClosePolicyEnum('parent_close_policy')
137
+ .notNull()
138
+ .default('terminate'),
139
+ scopeEntityType: text('scope_entity_type'),
140
+ scopeEntityId: text('scope_entity_id'),
141
+ tenantId: text('tenant_id'), // F9: always emitted (nullable) — runtime enforces on boundary via JOBS_MULTI_TENANT
142
+ tags: jsonb('tags').notNull().default({}).$type<Record<string, string>>(),
143
+ pool: text('pool').notNull(),
144
+ priority: integer('priority').notNull().default(0),
145
+ concurrencyKey: text('concurrency_key'),
146
+ dedupeKey: text('dedupe_key'),
147
+ status: jobRunStatusEnum('status').notNull().default('pending'),
148
+ input: jsonb('input').notNull().$type<Record<string, unknown>>(),
149
+ output: jsonb('output').$type<Record<string, unknown>>(),
150
+ error: jsonb('error').$type<JobRunError>(),
151
+ triggerSource: triggerSourceEnum('trigger_source').notNull(),
152
+ triggerRef: text('trigger_ref'),
153
+ runAt: timestamp('run_at', { withTimezone: true }).notNull().defaultNow(),
154
+ startedAt: timestamp('started_at', { withTimezone: true }),
155
+ finishedAt: timestamp('finished_at', { withTimezone: true }),
156
+ claimedAt: timestamp('claimed_at', { withTimezone: true }),
157
+ attempts: integer('attempts').notNull().default(0),
158
+ // Phase 3 placeholder — see ADR-025
159
+ waitKind: waitKindEnum('wait_kind'),
160
+ // Phase 3 placeholder — see ADR-025
161
+ resumeToken: text('resume_token'),
162
+ // Phase 3 placeholder — see ADR-025
163
+ waitDeadline: timestamp('wait_deadline', { withTimezone: true }),
164
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
165
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
166
+ },
167
+ (t) => ({
168
+ /** Claim query: ORDER BY priority DESC, run_at ASC. */
169
+ idxJobRunClaim: index('idx_job_run_claim').on(t.status, t.pool, t.runAt),
170
+ /** Tree traversal / cascade cancel. */
171
+ idxJobRunRoot: index('idx_job_run_root').on(t.rootRunId),
172
+ /** listForScope query. */
173
+ idxJobRunScope: index('idx_job_run_scope').on(t.scopeEntityType, t.scopeEntityId),
174
+ /** Idempotency collapse — partial index. */
175
+ idxJobRunDedupe: index('idx_job_run_dedupe')
176
+ .on(t.jobType, t.dedupeKey)
177
+ .where(sql`${t.dedupeKey} IS NOT NULL`),
178
+ /** Collision check — partial index. */
179
+ idxJobRunConcurrency: index('idx_job_run_concurrency')
180
+ .on(t.concurrencyKey)
181
+ .where(
182
+ sql`${t.concurrencyKey} IS NOT NULL AND ${t.status} IN ('pending','running')`,
183
+ ),
184
+ }),
185
+ );
186
+
187
+ export type JobRunRow = InferSelectModel<typeof jobRuns>;
188
+
189
+ // ─── job_step ───────────────────────────────────────────────────────────────
190
+
191
+ export const jobSteps = pgTable(
192
+ 'job_step',
193
+ {
194
+ id: uuid('id').primaryKey().defaultRandom(),
195
+ jobRunId: uuid('job_run_id').notNull().references(() => jobRuns.id),
196
+ stepId: text('step_id').notNull(),
197
+ kind: jobStepKindEnum('kind').notNull().default('task'),
198
+ /**
199
+ * Monotonic within run. integer (max ~2B per run) is sufficient —
200
+ * downgraded from ADR-022's bigint; revisit only if a single run
201
+ * ever exceeds 2 billion steps.
202
+ */
203
+ seq: integer('seq').notNull(),
204
+ status: jobStepStatusEnum('status').notNull().default('pending'),
205
+ input: jsonb('input').$type<Record<string, unknown>>(),
206
+ /** Memoised on success for replay. */
207
+ output: jsonb('output').$type<Record<string, unknown>>(),
208
+ error: jsonb('error').$type<JobRunError>(),
209
+ attempts: integer('attempts').notNull().default(0),
210
+ startedAt: timestamp('started_at', { withTimezone: true }),
211
+ finishedAt: timestamp('finished_at', { withTimezone: true }),
212
+ },
213
+ (t) => ({
214
+ /** No duplicate step IDs per run. */
215
+ idxJobStepRunStep: uniqueIndex('idx_job_step_run_step').on(t.jobRunId, t.stepId),
216
+ /** Ordered timeline reads. */
217
+ idxJobStepTimeline: index('idx_job_step_timeline').on(t.jobRunId, t.seq),
218
+ }),
219
+ );
220
+
221
+ export type JobStepRow = InferSelectModel<typeof jobSteps>;
@@ -0,0 +1,11 @@
1
+ ---
2
+ to: "<%= mainTsPath %>"
3
+ inject: true
4
+ after: "NestFactory.create"
5
+ skip_if: "<%= mainHookInjected %>"
6
+ ---
7
+ // JOBS — Embedded worker mode (optional)
8
+ // To run the job worker in-process (single-process deploy), add to AppModule imports:
9
+ // JobWorkerModule.forRoot({ mode: 'embedded' })
10
+ // For standalone worker (separate process), use worker.ts at the project root.
11
+ // See codegen.config.yaml jobs.worker_mode to toggle the documented default.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Hygen prompt.js — JOB-6 jobs subsystem scaffold.
3
+ *
4
+ * All locals are resolved by the CLI (src/cli/shared/jobs-scaffold-locals.ts)
5
+ * and forwarded as CLI args. This prompt.js coerces boolean-ish strings back
6
+ * into JS booleans so template `<% if (multiTenant) { %>` gates work — Hygen
7
+ * args arrive as strings, and `if ("false")` would render truthy in EJS.
8
+ *
9
+ * Invoked via:
10
+ * bunx hygen subsystem jobs \
11
+ * --workerPath <abs> --workerExists <'true'|''> \
12
+ * --mainTsPath <abs> --configPath <abs> --schemaPath <abs> \
13
+ * --multiTenant <'true'|'false'> --workerMode <embedded|standalone> \
14
+ * --appName <string>
15
+ */
16
+
17
+ function coerceBool(raw) {
18
+ if (raw === true) return true;
19
+ if (raw === false) return false;
20
+ if (typeof raw === "string") return raw.toLowerCase() === "true";
21
+ return false;
22
+ }
23
+
24
+ export default {
25
+ prompt: async ({ args }) => {
26
+ return {
27
+ appName: args.appName ?? "",
28
+ workerMode: args.workerMode === "standalone" ? "standalone" : "embedded",
29
+ multiTenant: coerceBool(args.multiTenant),
30
+ mainTsPath: args.mainTsPath ?? "src/main.ts",
31
+ configPath: args.configPath ?? "codegen.config.yaml",
32
+ // Hygen's skip_if treats any non-empty string as truthy, so we send an
33
+ // empty string when the file doesn't exist (CLI already does this).
34
+ workerExists: args.workerExists ?? "",
35
+ workerPath: args.workerPath ?? "worker.ts",
36
+ schemaPath:
37
+ args.schemaPath ?? "shared/subsystems/jobs/job-orchestration.schema.ts",
38
+ };
39
+ },
40
+ };
@@ -0,0 +1,82 @@
1
+ ---
2
+ to: "<%= workerPath %>"
3
+ unless_exists: true
4
+ ---
5
+ /**
6
+ * Standalone job worker entrypoint — emitted by `codegen subsystem install jobs`.
7
+ *
8
+ * Boots a Nest application context (NO HTTP listener) wiring the jobs domain
9
+ * module plus JobWorkerModule in `standalone` mode. Run with:
10
+ *
11
+ * bun worker.ts
12
+ *
13
+ * Embedded mode (single-process) is configured by importing
14
+ * JobWorkerModule.forRoot({ mode: 'embedded' }) inside AppModule instead —
15
+ * see the commented guidance injected into `src/main.ts`.
16
+ *
17
+ * SIGTERM triggers graceful shutdown bounded by SHUTDOWN_TIMEOUT_MS; after the
18
+ * timeout the process exits hard so orchestrators (systemd, Kubernetes) can
19
+ * reclaim the slot.
20
+ */
21
+ import 'reflect-metadata';
22
+ import { Logger, Module } from '@nestjs/common';
23
+ import { NestFactory } from '@nestjs/core';
24
+
25
+ import { DatabaseModule } from '@shared/database/database.module';
26
+ import { JobsDomainModule } from '@shared/subsystems/jobs/jobs-domain.module';
27
+ import { JobWorkerModule } from '@shared/subsystems/jobs/job-worker.module';
28
+
29
+ const SHUTDOWN_TIMEOUT_MS = 30_000;
30
+
31
+ @Module({
32
+ imports: [
33
+ DatabaseModule,
34
+ JobsDomainModule.forRoot({ backend: 'drizzle' }),
35
+ JobWorkerModule.forRoot({ mode: 'standalone' }),
36
+ ],
37
+ })
38
+ class WorkerAppModule {}
39
+
40
+ async function bootstrap(): Promise<void> {
41
+ const logger = new Logger('JobWorker');
42
+ const app = await NestFactory.createApplicationContext(WorkerAppModule, {
43
+ bufferLogs: false,
44
+ });
45
+
46
+ let shuttingDown = false;
47
+ const shutdown = async (signal: string): Promise<void> => {
48
+ if (shuttingDown) return;
49
+ shuttingDown = true;
50
+ logger.log(`${signal} received — shutting down (timeout ${SHUTDOWN_TIMEOUT_MS}ms)`);
51
+
52
+ const forceExit = setTimeout(() => {
53
+ logger.error(`shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms — forcing exit`);
54
+ process.exit(1);
55
+ }, SHUTDOWN_TIMEOUT_MS);
56
+ forceExit.unref();
57
+
58
+ try {
59
+ await app.close();
60
+ logger.log('shutdown complete');
61
+ process.exit(0);
62
+ } catch (err) {
63
+ logger.error('error during shutdown', err as Error);
64
+ process.exit(1);
65
+ }
66
+ };
67
+
68
+ process.on('SIGTERM', () => {
69
+ void shutdown('SIGTERM');
70
+ });
71
+ process.on('SIGINT', () => {
72
+ void shutdown('SIGINT');
73
+ });
74
+
75
+ logger.log('job worker started (standalone mode)');
76
+ }
77
+
78
+ bootstrap().catch((err) => {
79
+ // eslint-disable-next-line no-console
80
+ console.error('failed to bootstrap job worker', err);
81
+ process.exit(1);
82
+ });
@@ -0,0 +1,55 @@
1
+ ---
2
+ to: "<%= configPath %>"
3
+ inject: true
4
+ append: true
5
+ skip_if: "jobs:"
6
+ ---
7
+
8
+ jobs:
9
+ # ── Backend selection (core/extension model — see CLAUDE.md) ──
10
+ # 'drizzle' is the only Phase 1 backend. Future backends ('bullmq', etc.)
11
+ # implement the same core IJobOrchestrator contract but expose their own
12
+ # native features as opt-in extensions below.
13
+ backend: drizzle
14
+
15
+ # ── Backend-specific extensions (typed per backend) ──
16
+ # Each backend may publish its own extension keys. Unrecognised keys for
17
+ # the active backend produce a config validation warning at boot.
18
+ extensions:
19
+ drizzle:
20
+ # listen_notify: true # use Postgres LISTEN/NOTIFY to wake the
21
+ # # polling loop instead of (or alongside)
22
+ # # interval polling. Disabled by default.
23
+ poll_interval_ms: 1000
24
+ # bullmq: # Example shape for Phase 6+ BullMQ backend.
25
+ # bull_board: # Mount Bull Board admin UI.
26
+ # enabled: true
27
+ # mount_path: /admin/queues
28
+ # redis_url: redis://...
29
+
30
+ # ── Multi-tenancy (JOB-8) ──
31
+ multi_tenant: false # true → enforce tenantId on all calls
32
+
33
+ # ── Worker topology ──
34
+ worker_mode: embedded # embedded | standalone
35
+
36
+ # ── Pools (logical lanes; one worker per pool) ──
37
+ pools:
38
+ events_inbound:
39
+ queue: jobs-events-inbound
40
+ concurrency: 20
41
+ reserved: true # framework-only; user @JobHandler cannot target
42
+ events_change:
43
+ queue: jobs-events-change
44
+ concurrency: 30
45
+ reserved: true
46
+ events_outbound:
47
+ queue: jobs-events-outbound
48
+ concurrency: 10
49
+ reserved: true
50
+ interactive:
51
+ queue: jobs-interactive
52
+ concurrency: 20
53
+ batch:
54
+ queue: jobs-batch
55
+ concurrency: 5
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hygen prompt.js — #121 (F13) jobs config-block scaffold.
3
+ *
4
+ * Split from `templates/subsystem/jobs/` so the CLI can invoke the
5
+ * config-block inject step independently of the rest of the jobs scaffold.
6
+ * This lets `subsystem install jobs --force` preserve an existing `jobs:`
7
+ * block by skipping this action entirely, while `--force-config` opts in
8
+ * to regenerating it.
9
+ *
10
+ * Invoked via:
11
+ * bunx hygen subsystem jobs-config --configPath <abs>
12
+ */
13
+
14
+ export default {
15
+ prompt: async ({ args }) => {
16
+ return {
17
+ configPath: args.configPath ?? "codegen.config.yaml",
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hygen prompt.js — SYNC-7 sync subsystem scaffold.
3
+ *
4
+ * All locals are resolved by the CLI (src/cli/shared/sync-scaffold-locals.ts)
5
+ * and forwarded as CLI args. This prompt.js coerces boolean-ish strings back
6
+ * into JS booleans so template `<% if (multiTenant) { %>` gates work — Hygen
7
+ * args arrive as strings, and `if ("false")` would render truthy in EJS.
8
+ *
9
+ * Invoked via:
10
+ * bunx hygen subsystem sync \
11
+ * --configPath <abs> --schemaPath <abs> \
12
+ * --multiTenant <'true'|'false'> --appName <string>
13
+ *
14
+ * Unlike events, sync has NO codegen-emitted artifacts (no generated/ dir,
15
+ * no typed bus facade). So no `generatedKeepPath` local. Consumers that
16
+ * want a typed layer above the orchestrator build it themselves — the
17
+ * subsystem ships the substrate.
18
+ *
19
+ * Intentionally no starter entity YAMLs (sync_subscription, sync_run,
20
+ * sync_run_item). The subsystem owns those tables directly via SYNC-1's
21
+ * sync-audit.schema.ts; shipping entity YAMLs would generate redundant
22
+ * repositories/services that shadow the subsystem. Matches the epic's
23
+ * Phase 2 timing for `examples/sync/`.
24
+ */
25
+
26
+ function coerceBool(raw) {
27
+ if (raw === true) return true;
28
+ if (raw === false) return false;
29
+ if (typeof raw === "string") return raw.toLowerCase() === "true";
30
+ return false;
31
+ }
32
+
33
+ export default {
34
+ prompt: async ({ args }) => {
35
+ return {
36
+ appName: args.appName ?? "",
37
+ multiTenant: coerceBool(args.multiTenant),
38
+ configPath: args.configPath ?? "codegen.config.yaml",
39
+ schemaPath:
40
+ args.schemaPath ?? "shared/subsystems/sync/sync-audit.schema.ts",
41
+ };
42
+ },
43
+ };