@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.
- package/README.md +9 -4
- package/dist/src/cli/index.js +136 -128
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +25 -0
- package/dist/src/index.js.map +1 -1
- package/package.json +10 -1
- package/templates/entity/new/backend/application/commands/create.ejs.t +38 -1
- package/templates/entity/new/backend/application/commands/delete.ejs.t +41 -1
- package/templates/entity/new/backend/application/commands/update.ejs.t +42 -1
- package/templates/entity/new/backend/database/repository.ejs.t +33 -3
- package/templates/entity/new/backend/domain/repository-interface.ejs.t +6 -3
- package/templates/entity/new/backend/modules/core/module.ejs.t +6 -0
- package/templates/entity/new/backend/presentation/controller.ejs.t +32 -10
- package/templates/entity/new/clean-lite-ps/controller.ejs.t +72 -11
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +16 -2
- package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -1
- package/templates/entity/new/clean-lite-ps/module.ejs.t +45 -2
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +459 -98
- package/templates/entity/new/clean-lite-ps/repository.ejs.t +57 -4
- package/templates/entity/new/clean-lite-ps/search-controller.ejs.t +50 -0
- package/templates/entity/new/clean-lite-ps/service.ejs.t +98 -1
- package/templates/entity/new/clean-lite-ps/use-cases/create.ejs.t +150 -0
- package/templates/entity/new/clean-lite-ps/use-cases/delete.ejs.t +70 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id-with-fields.ejs.t +19 -0
- package/templates/entity/new/clean-lite-ps/use-cases/find-by-id.ejs.t +7 -3
- package/templates/entity/new/clean-lite-ps/use-cases/list-with-fields.ejs.t +17 -0
- package/templates/entity/new/clean-lite-ps/use-cases/search.ejs.t +63 -0
- package/templates/entity/new/clean-lite-ps/use-cases/update.ejs.t +153 -0
- package/templates/entity/new/prompt.js +284 -41
- package/templates/relationship/new/entity.ejs.t +2 -2
- package/templates/relationship/new/prompt.js +3 -7
- package/templates/relationship/new/service.ejs.t +1 -1
- package/templates/subsystem/bridge/generated-keep.ejs.t +4 -0
- package/templates/subsystem/bridge/prompt.js +36 -0
- package/templates/subsystem/bridge-config/codegen-config-bridge-block.ejs.t +20 -0
- package/templates/subsystem/bridge-config/prompt.js +20 -0
- package/templates/subsystem/events/domain-events.schema.ejs.t +81 -0
- package/templates/subsystem/events/generated-keep.ejs.t +4 -0
- package/templates/subsystem/events/prompt.js +39 -0
- package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +26 -0
- package/templates/subsystem/events-config/prompt.js +20 -0
- package/templates/subsystem/jobs/job-orchestration.schema.ejs.t +221 -0
- package/templates/subsystem/jobs/main-hook.ejs.t +11 -0
- package/templates/subsystem/jobs/prompt.js +40 -0
- package/templates/subsystem/jobs/worker.ejs.t +82 -0
- package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +55 -0
- package/templates/subsystem/jobs-config/prompt.js +20 -0
- package/templates/subsystem/sync/prompt.js +43 -0
- package/templates/subsystem/sync/sync-audit.schema.ejs.t +195 -0
- package/templates/subsystem/sync-config/codegen-config-sync-block.ejs.t +29 -0
- 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,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
|
+
};
|