@pattern-stack/codegen 0.9.1 → 0.10.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 +5 -0
- package/consumer-skills/bridge/SKILL.md +265 -0
- package/consumer-skills/codegen/SKILL.md +115 -0
- package/consumer-skills/entities/SKILL.md +111 -0
- package/consumer-skills/entities/families-and-queries.md +82 -0
- package/consumer-skills/entities/yaml-reference.md +118 -0
- package/consumer-skills/events/SKILL.md +71 -0
- package/consumer-skills/events/authoring-events.md +164 -0
- package/consumer-skills/events/typed-bus-and-outbox.md +163 -0
- package/consumer-skills/jobs/SKILL.md +66 -0
- package/consumer-skills/jobs/handler-authoring.md +236 -0
- package/consumer-skills/jobs/pools-and-ordering.md +161 -0
- package/consumer-skills/subsystems/SKILL.md +105 -0
- package/consumer-skills/subsystems/wiring-and-order.md +120 -0
- package/consumer-skills/sync/SKILL.md +134 -0
- package/consumer-skills/sync/audit-and-detection.md +302 -0
- package/consumer-skills/sync/change-sources-and-sinks.md +442 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +3 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.js +3 -0
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +3 -0
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.js +3 -0
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -0
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +9 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +3 -0
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +3 -0
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +913 -405
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.js +26 -4
- package/dist/src/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +3 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +9 -1
package/README.md
CHANGED
|
@@ -193,6 +193,11 @@ detection:
|
|
|
193
193
|
|
|
194
194
|
`codegen.config.yaml` in your project root:
|
|
195
195
|
|
|
196
|
+
Definition directories (`entities_dir`, `events_dir`) are discovered
|
|
197
|
+
recursively — a flat `entities/contact.yaml` and a domain-foldered
|
|
198
|
+
`entities/crm/contact.yaml` are both picked up. Group definitions into
|
|
199
|
+
per-domain subfolders freely; codegen walks the whole tree.
|
|
200
|
+
|
|
196
201
|
```yaml
|
|
197
202
|
paths:
|
|
198
203
|
backend_src: src
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bridge
|
|
3
|
+
description: Load when wiring the event-to-job bridge or authoring `@JobHandler.triggers` in a project that ran `codegen subsystem install bridge`. Triggers include declaring `triggers:` on a `@JobHandler`; deciding between an in-process subscriber, `eventFlow.publishAndStart`, and a bridge trigger; registering `BridgeModule.forRoot()` in `app.module.ts`; wiring the reserved `events_*` pools via `BRIDGE_RESERVED_POOLS`; same-aggregate ordering with a `concurrency` key; or running `codegen events consumers <type>` to find who reacts to an event.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
5
|
+
user-invocable: false
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<!-- managed by @pattern-stack/codegen — re-run `codegen skills install` to refresh. Edit the package source, not this vendored copy. -->
|
|
9
|
+
|
|
10
|
+
# Event-to-Job Bridge
|
|
11
|
+
|
|
12
|
+
The bridge is the durable, typed, observable path from *"an event was
|
|
13
|
+
published"* to *"a job was started"* in your app. It is its own subsystem —
|
|
14
|
+
the combiner between the `events` and `jobs` subsystems, owned by neither. You
|
|
15
|
+
opt into it by running `codegen subsystem install bridge`, which vendors the
|
|
16
|
+
runtime into `<paths.subsystems>/bridge/` (imported as
|
|
17
|
+
`@shared/subsystems/bridge`) and adds a `bridge:` block to
|
|
18
|
+
`codegen.config.yaml`.
|
|
19
|
+
|
|
20
|
+
Use this skill when you want one event to fan out to one or more durable async
|
|
21
|
+
jobs, authored by teams that don't know about each other. If you only need a
|
|
22
|
+
cheap in-process reaction, or a request-path job the caller already knows by
|
|
23
|
+
name, you probably want a lower tier instead — see the decision table below.
|
|
24
|
+
|
|
25
|
+
## Mental model: three tiers of event-driven work
|
|
26
|
+
|
|
27
|
+
You pick the tier by use case. The bridge is Tier 3.
|
|
28
|
+
|
|
29
|
+
| Tier | Mechanism | Durability | Latency | Use for |
|
|
30
|
+
|---|---|---|---|---|
|
|
31
|
+
| 1. Subscribe | `@OnEvent('x.y')` / `IEventBus.subscribe()` in-process | None (at-most-once) | ~ms | metrics, cache busts, logs |
|
|
32
|
+
| 2. Direct invoke | `eventFlow.publishAndStart(event, jobType, input)` | Yes (caller's tx) | ~1 poll cycle | request-path work, caller knows the job |
|
|
33
|
+
| 3. Bridge | `@JobHandler({ triggers: [{ event, map, when }] })` | Yes (outbox + ledger) | 2–3 poll cycles | durable async fanout, decoupled authors |
|
|
34
|
+
|
|
35
|
+
Tier 1 is events-only and never touches the bridge. Tier 2 and Tier 3 both
|
|
36
|
+
flow through the bridge at runtime — the difference is *who declares the
|
|
37
|
+
link*. In Tier 2 the caller writes the `publishAndStart` call explicitly; in
|
|
38
|
+
Tier 3 the job declares `triggers:` and the link fires automatically whenever
|
|
39
|
+
the event is published anywhere.
|
|
40
|
+
|
|
41
|
+
**How a trigger actually runs (Tier 3).** When the events outbox drain claims
|
|
42
|
+
a `domain_events` row, it inserts — in one per-event transaction — one ledger
|
|
43
|
+
row in `bridge_delivery` plus one *wrapper* job in a reserved `events_*` pool,
|
|
44
|
+
per matched trigger. A normal job worker claims the wrapper. The wrapper reads
|
|
45
|
+
the ledger, evaluates your `when:` predicate, applies your `map:` function, and
|
|
46
|
+
calls the orchestrator to start your real job in *its* declared pool, parented
|
|
47
|
+
to the wrapper so cascade-cancel works. Then it marks the delivery `delivered`.
|
|
48
|
+
The reserved `events_*` pools thus host cheap wrappers (high concurrency); your
|
|
49
|
+
own pools host the actual work (concurrency tuned to its scarce resource).
|
|
50
|
+
|
|
51
|
+
**The ledger is the source of truth.** `bridge_delivery` has a
|
|
52
|
+
`UNIQUE (event_id, trigger_id)` constraint. That is the idempotency primitive
|
|
53
|
+
— it dedups outbox replay, and it dedups the case where a caller uses
|
|
54
|
+
`publishAndStart` AND the same job also declares a matching `triggers:` entry
|
|
55
|
+
(exactly one execution per event/trigger pair, whichever path got there first).
|
|
56
|
+
|
|
57
|
+
## Install and wiring
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
codegen subsystem install bridge
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Config block (`codegen.config.yaml`):
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
bridge:
|
|
67
|
+
backend: drizzle # 'drizzle' (production) or 'memory' (tests)
|
|
68
|
+
multi_tenant: false # pair with BridgeModule.forRoot({ multiTenant: true })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Register the module in `app.module.ts`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { BridgeModule } from '@shared/subsystems/bridge';
|
|
75
|
+
|
|
76
|
+
BridgeModule.forRoot({ backend: 'drizzle', multiTenant: false }),
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Wire the reserved `events_*` pools
|
|
80
|
+
|
|
81
|
+
The bridge wrappers run in three reserved pools — `events_inbound`,
|
|
82
|
+
`events_change`, `events_outbound`. A worker process must actually *drain* them,
|
|
83
|
+
or wrappers sit `pending` forever (and `BridgeModule` fails fast at boot). The
|
|
84
|
+
exported `BRIDGE_RESERVED_POOLS` is the list of those three pool names — spread
|
|
85
|
+
it into the active-pools list of your `JobWorkerModule.forRoot` (see the
|
|
86
|
+
`subsystems` skill for the full wiring + order):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { BRIDGE_RESERVED_POOLS } from '@shared/subsystems/bridge';
|
|
90
|
+
import { JobWorkerModule } from '@shared/subsystems/jobs';
|
|
91
|
+
|
|
92
|
+
JobWorkerModule.forRoot({
|
|
93
|
+
mode: 'embedded',
|
|
94
|
+
backend: 'drizzle',
|
|
95
|
+
// active pool names this worker drains — include the reserved lanes:
|
|
96
|
+
pools: ['interactive', 'batch', ...BRIDGE_RESERVED_POOLS],
|
|
97
|
+
}),
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Pool *definitions* (concurrency per lane) live in `codegen.config.yaml` under
|
|
101
|
+
`jobs.pools`, not in `forRoot`; `JobWorkerModule.forRoot({ pools })` only names
|
|
102
|
+
which lanes this process drains. (Alternatively, `JobWorkerModule.forRoot({
|
|
103
|
+
allPools: true })` drains every pool including the reserved ones — that's what
|
|
104
|
+
the standalone `worker.ts` uses.) Wrappers are cheap (read ledger → evaluate
|
|
105
|
+
`when:` → start the user job → update ledger), so the reserved lanes can run at
|
|
106
|
+
high concurrency safely.
|
|
107
|
+
|
|
108
|
+
## Authoring triggers
|
|
109
|
+
|
|
110
|
+
Triggers are **job-owned**. Declare them on the `@JobHandler` decorator of the
|
|
111
|
+
job you want to run — never on the event side.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
@JobHandler<SendWelcomeEmailInput>('send_welcome_email', {
|
|
115
|
+
pool: 'outbound_email',
|
|
116
|
+
triggers: [
|
|
117
|
+
{
|
|
118
|
+
event: 'user.created',
|
|
119
|
+
map: (e) => ({ userId: e.aggregateId, email: e.payload.email }),
|
|
120
|
+
when: (e) => e.payload.email !== undefined, // optional
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
})
|
|
124
|
+
export class SendWelcomeEmailJob extends JobHandlerBase<SendWelcomeEmailInput> {
|
|
125
|
+
// ...
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`map:` and `when:` are typed TS callbacks — they typecheck against the payload
|
|
130
|
+
type of the event you named. They must be **self-contained**: no calls to
|
|
131
|
+
project helpers, services, or imports. The codegen inlines the arrow body
|
|
132
|
+
verbatim into the generated bridge registry, so anything outside the arrow's
|
|
133
|
+
own scope will not be in scope there.
|
|
134
|
+
|
|
135
|
+
After authoring or changing a trigger, regenerate the registry
|
|
136
|
+
(`codegen entity new --all` or your project's gen-all task). Unknown event
|
|
137
|
+
types referenced in `triggers[].event` fail the build at generation time —
|
|
138
|
+
that is the build-time validation against the event registry, and it is the
|
|
139
|
+
primary safety net.
|
|
140
|
+
|
|
141
|
+
If `when:` returns false at runtime, the wrapper records the delivery as
|
|
142
|
+
`skipped` (with a reason) and does not start your job.
|
|
143
|
+
|
|
144
|
+
## Ordering
|
|
145
|
+
|
|
146
|
+
**The default configuration gives parallelism, not ordering.** Two events of
|
|
147
|
+
the same type may be processed concurrently; same-aggregate ordering is NOT
|
|
148
|
+
guaranteed out of the box. Pick the knob that matches your real requirement:
|
|
149
|
+
|
|
150
|
+
1. **`concurrency` key on the user job** *(recommended when ordering matters)*
|
|
151
|
+
— granular per-aggregate serialization, parallelism preserved across
|
|
152
|
+
unrelated aggregates. The `key` callback receives the job input:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
@JobHandler<ProvisionInput>('provision_workspace', {
|
|
156
|
+
concurrency: { key: (input) => input.accountId, collisionMode: 'queue' },
|
|
157
|
+
triggers: [/* ... */],
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
2. **`events_<direction>` pool `concurrency: 1`** *(blunt)* — serializes
|
|
162
|
+
**every** wrapper in that direction, i.e. every bridge fanout for that
|
|
163
|
+
direction end to end. Simplest config, highest throughput cost. Use only
|
|
164
|
+
when every event in the direction genuinely needs strict order.
|
|
165
|
+
|
|
166
|
+
## When NOT to use the bridge
|
|
167
|
+
|
|
168
|
+
The bridge adds 2–3 outbox poll cycles of latency (typically 1–3 s). If you
|
|
169
|
+
need request-path durability at lower latency and the caller already knows the
|
|
170
|
+
job, use the `IEventFlow` facade directly (Tier 2 — runs off the next poll
|
|
171
|
+
cycle, in the caller's transaction):
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
constructor(private eventFlow: IEventFlow) {}
|
|
175
|
+
|
|
176
|
+
async signup(input: SignupInput, tx: Tx): Promise<void> {
|
|
177
|
+
await this.eventFlow.publishAndStart(
|
|
178
|
+
'user.created',
|
|
179
|
+
'provision_workspace',
|
|
180
|
+
{ userId: input.id },
|
|
181
|
+
{ tx },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`IEventFlow` exposes exactly two verbs: `publish()` and `publishAndStart()`.
|
|
187
|
+
All request-path publishing goes through this facade, not through `IEventBus`
|
|
188
|
+
directly. Tier 1 subscribers stay declarative (`@OnEvent`) and bypass it.
|
|
189
|
+
|
|
190
|
+
Decision table:
|
|
191
|
+
|
|
192
|
+
| Need | Tier | Pattern |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| Cheap in-process reaction (metrics, cache bust) | 1 | `@OnEvent('x.y')` or `IEventBus.subscribe` |
|
|
195
|
+
| Request-path durable, caller knows the job | 2 | `eventFlow.publishAndStart(...)` |
|
|
196
|
+
| Async fanout, decoupled authors, multiple handlers per event | 3 | `@JobHandler.triggers[]` (the bridge) |
|
|
197
|
+
|
|
198
|
+
## Discovering fanout: `codegen events consumers <type>`
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
codegen events consumers user.created
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Prints a greppable report with all three tiers and file:line citations:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
Event: user.created
|
|
208
|
+
Tier 3 — Bridge triggers (2):
|
|
209
|
+
- send_welcome_email#0 (src/jobs/send-welcome-email.job.ts:14)
|
|
210
|
+
- provision_workspace#0 (src/jobs/provision-workspace.job.ts:18)
|
|
211
|
+
Tier 2 — Direct invoke via publishAndStart (1):
|
|
212
|
+
- src/use-cases/signup.uc.ts:42
|
|
213
|
+
Tier 1 — Subscribers (1):
|
|
214
|
+
- MetricsListener.onCreate @OnEvent('user.created') at src/observability/metrics.ts:28
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Unknown event types print a suggestion-bearing warning to stderr but the
|
|
218
|
+
command still exits 0. If the scan finds zero `publishAndStart` call sites but
|
|
219
|
+
`EventFlowService` exists in the codebase, a fallback warning prints — the AST
|
|
220
|
+
scan can miss non-standard injection (property injection, dynamic dispatch);
|
|
221
|
+
grep for `publishAndStart` to verify Tier 2 manually.
|
|
222
|
+
|
|
223
|
+
## Multi-tenancy
|
|
224
|
+
|
|
225
|
+
Set `multi_tenant: true` in the config block and pass
|
|
226
|
+
`BridgeModule.forRoot({ backend: 'drizzle', multiTenant: true })`. When on,
|
|
227
|
+
three write-side sites throw `MissingTenantIdError` if `tenantId === undefined`
|
|
228
|
+
(explicit `null` passes, for deliberate cross-tenant work): the
|
|
229
|
+
`publishAndStart` request-path entry, the wrapper handler entry, and the
|
|
230
|
+
delivery-repo write boundary. Event metadata carries `tenantId` from the typed
|
|
231
|
+
event bus; the bridge threads it into the job's `tenant_id` when it starts your
|
|
232
|
+
job. Your bridge, events, and jobs configs must all agree on the flag.
|
|
233
|
+
|
|
234
|
+
## Renaming or removing a trigger
|
|
235
|
+
|
|
236
|
+
The generated `trigger_id` is `<jobType>#<index>` — stable across generations,
|
|
237
|
+
so replays resolve to the same ledger row. Renaming a `@JobHandler('<name>')`
|
|
238
|
+
changes that id, so in-flight `pending` deliveries with the old `trigger_id`
|
|
239
|
+
become orphans: the wrapper detects the missing registry entry, marks the
|
|
240
|
+
delivery `skipped` with `skip_reason='trigger_unregistered'`, and stops. No
|
|
241
|
+
auto-migration, no replay. If you need the old deliveries to run under the new
|
|
242
|
+
name, drain the queue before deploying the rename.
|
|
243
|
+
|
|
244
|
+
## Do not
|
|
245
|
+
|
|
246
|
+
- **Do not collapse the three tiers** into "the bridge is the only path."
|
|
247
|
+
Tier 1 stays valid for cheap in-process reactions; Tier 2 is the request-path
|
|
248
|
+
durable option; Tier 3 is async fanout. The right tool depends on durability
|
|
249
|
+
and latency.
|
|
250
|
+
- **Do not put your `@JobHandler` classes on reserved `events_*` pools.**
|
|
251
|
+
Module init rejects it. Those pools host framework wrappers only; your work
|
|
252
|
+
lives in a pool you declare.
|
|
253
|
+
- **Do not declare triggers on the event side.** Triggers are job-owned. The
|
|
254
|
+
events subsystem stays zero-knowledge about jobs.
|
|
255
|
+
- **Do not reference helpers, services, or imports inside `map:` / `when:`.**
|
|
256
|
+
They are inlined verbatim into the generated registry and must be
|
|
257
|
+
self-contained.
|
|
258
|
+
- **Do not skip regenerating the registry** after changing a trigger. The
|
|
259
|
+
build-time validation against the event registry only runs at generation; a
|
|
260
|
+
stale registry silently drifts from your decorators.
|
|
261
|
+
- **Do not expect ordering from the default config.** Add a `concurrency` key
|
|
262
|
+
(granular) or set a reserved pool's `concurrency: 1` (blunt) when
|
|
263
|
+
same-aggregate order matters.
|
|
264
|
+
- **Do not drop `tenantId`** when `multi_tenant: true`. Missing tenant context
|
|
265
|
+
throws `MissingTenantIdError` at the write-side enforcement sites.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codegen
|
|
3
|
+
description: >-
|
|
4
|
+
Load when working in a project that uses @pattern-stack/codegen to scaffold a
|
|
5
|
+
NestJS + Drizzle backend from YAML — i.e. when the request is to add or change
|
|
6
|
+
an entity / module / CRUD resource, generate code from `entities/*.yaml`, run
|
|
7
|
+
the `codegen` (aka `cdp`) CLI, install or wire an infrastructure subsystem, or
|
|
8
|
+
refresh the project after a package upgrade. This is the entry-point router;
|
|
9
|
+
it points at the focused `entities`, `subsystems`, `jobs`, `events`, `bridge`,
|
|
10
|
+
and `sync` skills for deep work.
|
|
11
|
+
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
12
|
+
user-invocable: false
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<!-- managed by @pattern-stack/codegen — re-run `codegen skills install` to refresh. Edit the package source, not this vendored copy. -->
|
|
16
|
+
|
|
17
|
+
# Using @pattern-stack/codegen in this project
|
|
18
|
+
|
|
19
|
+
This project generates its backend (domain entities, repositories, services,
|
|
20
|
+
controllers, DTOs, use cases, Drizzle schemas, NestJS modules) from YAML entity
|
|
21
|
+
definitions, following Clean Architecture. You author small YAML files and run
|
|
22
|
+
the `codegen` CLI; the generator owns a few directories and never touches the
|
|
23
|
+
rest of your app.
|
|
24
|
+
|
|
25
|
+
The CLI binary is `codegen` (alias `cdp`). Every noun supports `--json` for
|
|
26
|
+
machine-readable output and `--cwd <path>` to target another project root.
|
|
27
|
+
|
|
28
|
+
## Mental model
|
|
29
|
+
|
|
30
|
+
- **You own**: `entities/*.yaml`, `events/*.yaml`, `app.module.ts`, `main.ts`,
|
|
31
|
+
`database.module.ts`, `codegen.config.yaml`, and your hand-written use cases /
|
|
32
|
+
adapters.
|
|
33
|
+
- **Codegen owns** (don't hand-edit — regenerated every run):
|
|
34
|
+
- `src/generated/modules.ts` — the `GENERATED_MODULES` barrel
|
|
35
|
+
- `src/generated/schema.ts` — the Drizzle schema barrel
|
|
36
|
+
- the per-entity module tree (`src/modules/<plural>/…` in clean-lite-ps)
|
|
37
|
+
- **The package vendors** managed copies of its runtime into `src/shared/**`
|
|
38
|
+
(base classes, types, the `DRIZZLE` token, the Zod pipe, the OpenAPI
|
|
39
|
+
registry) and installed subsystems into `<subsystems-root>/<name>/`. Treat
|
|
40
|
+
these as generated output: don't hand-edit; subclass instead. `codegen
|
|
41
|
+
update` refreshes them after a package bump.
|
|
42
|
+
|
|
43
|
+
The generation pipeline: `YAML → parse → analyze → templates → code`. After any
|
|
44
|
+
generation run the two barrels are rewritten and you wire them into
|
|
45
|
+
`app.module.ts` exactly once — codegen never edits that file again.
|
|
46
|
+
|
|
47
|
+
## Routing — load the focused skill for deep work
|
|
48
|
+
|
|
49
|
+
| Task | Read |
|
|
50
|
+
|---|---|
|
|
51
|
+
| Author / change an entity YAML (fields, families, queries, EAV, relationships) | the `entities` skill |
|
|
52
|
+
| Install or wire an infrastructure subsystem; get the `forRoot` registration order right | the `subsystems` skill |
|
|
53
|
+
| Write a background `@JobHandler`, configure pools, set concurrency/ordering | the `jobs` skill |
|
|
54
|
+
| Author a domain event, publish via the outbox, use the typed event bus | the `events` skill |
|
|
55
|
+
| React to an event with a durable async job (the event-to-job bridge) | the `bridge` skill |
|
|
56
|
+
| Pull/push data from an external system (`IChangeSource` / `ISyncSink`) | the `sync` skill |
|
|
57
|
+
|
|
58
|
+
## CLI quick reference
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Project lifecycle
|
|
62
|
+
codegen init # scaffold this project's shared layer + config + skills
|
|
63
|
+
codegen project scan # detect framework/ORM/architecture → propose config
|
|
64
|
+
codegen project config # print the resolved codegen.config.yaml
|
|
65
|
+
codegen update # re-sync vendored runtime + subsystems + skills after a package bump
|
|
66
|
+
|
|
67
|
+
# Entities
|
|
68
|
+
codegen entity new entities/<file>.yaml # generate one entity
|
|
69
|
+
codegen entity new --all # regenerate every entity in entities/
|
|
70
|
+
codegen entity new --all --dry-run # preview
|
|
71
|
+
codegen entity list # tabular list
|
|
72
|
+
codegen entity validate --strict # validate YAML + cross-refs (warnings fail)
|
|
73
|
+
|
|
74
|
+
# Subsystems (see the `subsystems` skill for wiring + order)
|
|
75
|
+
codegen subsystem # summary: installed vs available
|
|
76
|
+
codegen subsystem install <name> # vendor a subsystem's runtime + inject its config block
|
|
77
|
+
codegen subsystem list
|
|
78
|
+
|
|
79
|
+
# Skills
|
|
80
|
+
codegen skills install # (re)vendor these consumer skills into .claude/skills
|
|
81
|
+
codegen skills list
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Non-obvious rules
|
|
85
|
+
|
|
86
|
+
- **YAML is `snake_case`; generated TS properties are `camelCase`.** The
|
|
87
|
+
templates derive `accountId` from `account_id`. Entity names are singular
|
|
88
|
+
`snake_case` (`opportunity`).
|
|
89
|
+
- **Two architectures, mutually exclusive.** `generate.architecture` in
|
|
90
|
+
`codegen.config.yaml` is either `clean-lite-ps` (the supported consumer
|
|
91
|
+
default — lighter per-entity module layout) or `clean` (full split). Don't mix.
|
|
92
|
+
- **Barrels are wired once.** After the first `entity new`, add
|
|
93
|
+
`...GENERATED_MODULES` to `app.module.ts` and `export * from
|
|
94
|
+
'./generated/schema'` to your schema root. Codegen keeps the barrel contents
|
|
95
|
+
fresh; you never re-touch `app.module.ts` for new entities.
|
|
96
|
+
- **Migrations are not `drizzle-kit push` in shared/CI/prod.** Generate
|
|
97
|
+
reviewable SQL with Atlas (`atlas migrate diff` → review → `atlas migrate
|
|
98
|
+
apply`). `push` is dev-loop-only.
|
|
99
|
+
- **Upgrades need a re-sync.** After `bun add @pattern-stack/codegen@latest`, run
|
|
100
|
+
`codegen update` — the vendored `src/shared/**` and installed subsystems are
|
|
101
|
+
otherwise stale against the new package.
|
|
102
|
+
|
|
103
|
+
## Do not
|
|
104
|
+
|
|
105
|
+
- **Do not hand-edit anything under `src/generated/`** or the vendored
|
|
106
|
+
`src/shared/**` runtime files — the next `entity new` / `codegen update`
|
|
107
|
+
overwrites them. Need different behavior? Subclass the base in your own module.
|
|
108
|
+
- **Do not declare a fresh `DRIZZLE` token.** Import the one from
|
|
109
|
+
`@shared/constants/tokens`; a second token has a different identity and DI
|
|
110
|
+
won't resolve.
|
|
111
|
+
- **Do not add tables directly to `src/generated/schema.ts`.** Hand-authored
|
|
112
|
+
tables go in your own file and are combined in the schema root re-export.
|
|
113
|
+
- **Do not reach for `clean` vs `clean-lite-ps` arbitrarily.** Match the
|
|
114
|
+
project's existing `generate.architecture`; switching mid-project rewrites the
|
|
115
|
+
whole module layout.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: entities
|
|
3
|
+
description: >-
|
|
4
|
+
Load when authoring or changing an entity definition for a project that uses
|
|
5
|
+
@pattern-stack/codegen — creating `entities/<name>.yaml`, choosing an entity
|
|
6
|
+
family (Synced / Activity / Metadata / Knowledge / Base), adding fields,
|
|
7
|
+
behaviors, relationships, declarative `queries:`, or opting an entity into EAV
|
|
8
|
+
custom fields. Covers what each YAML block generates and the layer rules the
|
|
9
|
+
generated code obeys.
|
|
10
|
+
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
11
|
+
user-invocable: false
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<!-- managed by @pattern-stack/codegen — re-run `codegen skills install` to refresh. Edit the package source, not this vendored copy. -->
|
|
15
|
+
|
|
16
|
+
# Authoring entities
|
|
17
|
+
|
|
18
|
+
An entity is one `entities/<name>.yaml` file. Running `codegen entity new
|
|
19
|
+
entities/<name>.yaml` (or `codegen entity new --all`) turns it into a full
|
|
20
|
+
vertical slice: a Drizzle table, a repository (extending a family base class), a
|
|
21
|
+
service, use cases, a controller with Zod-validated routes, DTOs, and a NestJS
|
|
22
|
+
module — plus an entry in the `GENERATED_MODULES` and schema barrels.
|
|
23
|
+
|
|
24
|
+
## Mental model
|
|
25
|
+
|
|
26
|
+
- **One YAML → one module.** You describe *what* the entity is (fields,
|
|
27
|
+
relationships, behaviors, queries); the templates produce the *how* (CRUD,
|
|
28
|
+
validation, wiring).
|
|
29
|
+
- **Family = inherited capability.** Every entity picks a `pattern` (family).
|
|
30
|
+
The family decides which extra repository/service methods you get for free on
|
|
31
|
+
top of the standard CRUD set. See `families-and-queries.md`.
|
|
32
|
+
- **Declarative queries beat hand-written finders.** A `queries:` block
|
|
33
|
+
generates typed repository methods, interface signatures, injectable query
|
|
34
|
+
use cases, and module registration. See `families-and-queries.md`.
|
|
35
|
+
- **Naming**: YAML is `snake_case` (matches DB columns); generated TS
|
|
36
|
+
properties are `camelCase`; entity `name` is singular `snake_case`.
|
|
37
|
+
|
|
38
|
+
## Routing
|
|
39
|
+
|
|
40
|
+
| For | Read |
|
|
41
|
+
|---|---|
|
|
42
|
+
| The full YAML block reference — fields, types, behaviors, relationships, EAV flags, `generate:` | `yaml-reference.md` |
|
|
43
|
+
| Choosing a family, the methods each family adds, and the `queries:` block shapes | `families-and-queries.md` |
|
|
44
|
+
|
|
45
|
+
## Minimum viable entity
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
entity:
|
|
49
|
+
name: account # singular snake_case
|
|
50
|
+
pattern: Synced # Base | Synced | Activity | Metadata | Knowledge (or app-defined)
|
|
51
|
+
|
|
52
|
+
fields:
|
|
53
|
+
name:
|
|
54
|
+
type: string
|
|
55
|
+
required: true
|
|
56
|
+
email:
|
|
57
|
+
type: string
|
|
58
|
+
index: true
|
|
59
|
+
status:
|
|
60
|
+
type: enum
|
|
61
|
+
choices: [active, inactive]
|
|
62
|
+
|
|
63
|
+
behaviors:
|
|
64
|
+
- timestamps # createdAt, updatedAt
|
|
65
|
+
- soft_delete # deletedAt + automatic query filtering
|
|
66
|
+
|
|
67
|
+
queries:
|
|
68
|
+
- by: [email]
|
|
69
|
+
unique: true # → FindAccountByEmail use case (unique)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
codegen entity new entities/account.yaml
|
|
74
|
+
# barrels auto-update — no manual wiring for the new module
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Layer rules the generated code obeys
|
|
78
|
+
|
|
79
|
+
Generated code is layered; your hand-written use cases must respect the same
|
|
80
|
+
boundaries:
|
|
81
|
+
|
|
82
|
+
- **Repository** — single table. Extends a family base. No business logic.
|
|
83
|
+
Write methods take an optional `tx?: DrizzleTx`.
|
|
84
|
+
- **Service** — one aggregate. Composes repositories; may read cross-domain;
|
|
85
|
+
may call same-domain services. **May NOT write cross-domain.** This is the
|
|
86
|
+
mandatory API boundary.
|
|
87
|
+
- **Use case** — a workflow. Composes services (including cross-domain), owns
|
|
88
|
+
the transaction for cross-domain writes, emits events, calls external ports.
|
|
89
|
+
- **Controller** — thin adapter. Calls use cases only. `@Body()` is run through
|
|
90
|
+
`ZodValidationPipe` (422 on failure); `GET :id` throws 404 when the row is
|
|
91
|
+
missing or soft-deleted.
|
|
92
|
+
|
|
93
|
+
## Non-obvious rules
|
|
94
|
+
|
|
95
|
+
- **`generate.writes: true` (default) emits POST/PATCH/DELETE** + create/update/
|
|
96
|
+
delete use cases. Set it `false` for read-only entities.
|
|
97
|
+
- **Cross-entity references must resolve.** If `account.yaml` references
|
|
98
|
+
`contact`, the analyzer needs `contact.yaml` present; `codegen entity new
|
|
99
|
+
--all` is the safe way to regenerate a set with cross-refs.
|
|
100
|
+
- **Regenerating is safe and idempotent.** Re-run after any YAML edit; the
|
|
101
|
+
module tree + barrels are codegen-owned.
|
|
102
|
+
- **Validate before generating** when unsure: `codegen entity validate --strict`.
|
|
103
|
+
|
|
104
|
+
## Do not
|
|
105
|
+
|
|
106
|
+
- **Do not hand-edit generated module files.** Override by composing in your own
|
|
107
|
+
module / subclassing the generated service.
|
|
108
|
+
- **Do not put business logic in a repository** or cross-domain writes in a
|
|
109
|
+
service — the layer rules above are enforced by convention and reviewed.
|
|
110
|
+
- **Do not invent field types.** Use the documented set (`yaml-reference.md`);
|
|
111
|
+
unknown types fail validation.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!-- managed by @pattern-stack/codegen — re-run `codegen skills install` to refresh. Edit the package source, not this vendored copy. -->
|
|
2
|
+
|
|
3
|
+
# Entity families & declarative queries
|
|
4
|
+
|
|
5
|
+
## Families (the `pattern:` field)
|
|
6
|
+
|
|
7
|
+
Every entity declares a `pattern`. The family decides which methods the
|
|
8
|
+
generated repository and service inherit on top of the standard CRUD set. The
|
|
9
|
+
base classes are vendored into `@shared/base-classes/*` at project init.
|
|
10
|
+
|
|
11
|
+
**Standard CRUD set (every family):** `findById`, `findByIds`, `list`, `count`,
|
|
12
|
+
`exists`, `create`, `update`, `delete`, `upsertMany`. Every write method accepts
|
|
13
|
+
an optional `tx?: DrizzleTx` for transactional composition.
|
|
14
|
+
|
|
15
|
+
| Family | Use it for | Adds on top of CRUD |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `Base` | plain tables with no special access pattern | nothing — standard CRUD only |
|
|
18
|
+
| `Synced` | records mirrored from an external system (have an external id + per-user visibility) | `findByExternalId`, `findAllByUserId`, `findVisibleByUserId`, `syncUpsert` |
|
|
19
|
+
| `Activity` | time-ordered activity/event rows tied to a parent | `findByDateRange`, `findByUserId`, `findByOpportunityId`, `findRecentByOpportunityId` |
|
|
20
|
+
| `Metadata` | key/value or definition/value rows describing other entities | `findByEntityIdAndType`, `listByEntityId`, `listHistoryByEntityId` |
|
|
21
|
+
| `Knowledge` | semantically-searchable knowledge rows (pgvector at runtime) | `semanticSearch`, `findPendingByOpportunityId`, `updateStatus`, `updateStatusBatch` |
|
|
22
|
+
|
|
23
|
+
Choosing a family:
|
|
24
|
+
- Mirrors an external system (CRM, etc.)? → `Synced` (often paired with the
|
|
25
|
+
`sync` skill).
|
|
26
|
+
- Append-only timeline tied to a parent record? → `Activity`.
|
|
27
|
+
- Describes/annotates other entities (incl. the EAV value table)? → `Metadata`.
|
|
28
|
+
- Vector search / RAG? → `Knowledge`.
|
|
29
|
+
- None of the above? → `Base`.
|
|
30
|
+
|
|
31
|
+
App-defined patterns are also supported (a project can register its own family
|
|
32
|
+
base); use the project's existing convention if one is present.
|
|
33
|
+
|
|
34
|
+
## Declarative queries (the `queries:` block)
|
|
35
|
+
|
|
36
|
+
A `queries:` entry generates a typed repository method, the interface
|
|
37
|
+
signature, an injectable query use case, and its module registration — no
|
|
38
|
+
hand-written finder.
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
queries:
|
|
42
|
+
- by: [user_id] # → findByUserId()
|
|
43
|
+
- by: [email] # → findByEmail() (unique)
|
|
44
|
+
unique: true
|
|
45
|
+
- by: [account_id] # → findByAccountId(), ordered
|
|
46
|
+
order: created_at desc
|
|
47
|
+
- by: [user_id, account_id] # → findByUserIdAndAccountId()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Shapes:
|
|
51
|
+
- **`by: [col, …]`** — equality finder on one or more columns. Method name is
|
|
52
|
+
derived (`findByUserIdAndAccountId`). Multi-column finders AND the conditions.
|
|
53
|
+
- **`unique: true`** — returns a single row (or null) instead of an array, and
|
|
54
|
+
marks the underlying index unique.
|
|
55
|
+
- **`order: <col> <dir>`** — default ordering for the finder (e.g. `created_at
|
|
56
|
+
desc`).
|
|
57
|
+
|
|
58
|
+
### Filtered search with pagination
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
queries:
|
|
62
|
+
- name: search # → SearchContacts use case + GET /contacts/search
|
|
63
|
+
filters: [user_id, account_id, email] # optional equality filters
|
|
64
|
+
search: name # ilike column for free-text
|
|
65
|
+
paginate: true # returns { items, total, limit, offset }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A `name`d query with `filters`/`search`/`paginate` generates a search use case
|
|
69
|
+
and a `GET /<plural>/search` route. `paginate: true` makes the route accept
|
|
70
|
+
`limit`/`offset` and return a paged envelope.
|
|
71
|
+
|
|
72
|
+
## Non-obvious rules
|
|
73
|
+
|
|
74
|
+
- **Finder names are generated** from the column list — don't also hand-write a
|
|
75
|
+
finder of the same name; compose on top instead.
|
|
76
|
+
- **Unique finders return a single nullable result**; non-unique return arrays.
|
|
77
|
+
- **`order:` is the default sort**, not a parameter — add a `queries:` search
|
|
78
|
+
entry if you need caller-controlled ordering.
|
|
79
|
+
- **Family methods assume their columns exist.** `Synced` expects an external-id
|
|
80
|
+
+ user-visibility shape; `Activity` expects a parent FK like
|
|
81
|
+
`opportunity_id`. If your table doesn't fit, pick `Base` and add explicit
|
|
82
|
+
`queries:`.
|