@cosmicdrift/kumiko-framework 0.2.3 → 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/CHANGELOG.md +52 -0
- package/package.json +124 -39
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/compliance/profiles.ts +8 -8
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +3 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1804
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +88 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/factories.ts +12 -12
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +7 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +49 -1
- package/src/engine/feature-ast/render.ts +17 -1
- package/src/engine/index.ts +45 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
- package/src/engine/pattern-library/library.ts +42 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +2 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +92 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +12 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +2 -2
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- package/src/engine/feature-ast/extractors.ts +0 -2602
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Step-Vocabulary Types — see docs/plans/architecture/intern/step-vocabulary.md
|
|
2
|
+
//
|
|
3
|
+
// M.1 minimal scope:
|
|
4
|
+
// - Steps execute against the existing HandlerContext (no per-step subset).
|
|
5
|
+
// - steps-accumulator is Record<string, unknown> (no tuple-reduce typing).
|
|
6
|
+
// - Resolvers receive the full PipelineCtx as one argument.
|
|
7
|
+
//
|
|
8
|
+
// Strict typing on appendEvent inside steps is deferred to a later pass
|
|
9
|
+
// (see TS-typing notes in the design doc). M.1 uses unsafeAppendEvent
|
|
10
|
+
// semantics under the hood for r.step.aggregate.appendEvent.
|
|
11
|
+
|
|
12
|
+
import type { SQL, Table } from "drizzle-orm";
|
|
13
|
+
import type { EventStoreExecutor } from "../../db/event-store-executor";
|
|
14
|
+
import type { KumikoEventTypeMap } from "./event-type-map";
|
|
15
|
+
import type { HandlerContext, WriteEvent, WriteResult } from "./handlers";
|
|
16
|
+
import type { SaveContext } from "./hooks";
|
|
17
|
+
import type { EntityId } from "./identifiers";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The kind discriminator for a step instance — matches the step's
|
|
21
|
+
* registration name in the step-registry (e.g. "return", "compute",
|
|
22
|
+
* "aggregate.create"). Steps register themselves at module-load time
|
|
23
|
+
* via defineStep().
|
|
24
|
+
*/
|
|
25
|
+
export type StepKind = string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pipeline-side context handed to step argument resolvers.
|
|
29
|
+
*
|
|
30
|
+
* Contains the full HandlerContext (no per-step subset in M.1) plus
|
|
31
|
+
* the accumulated `steps` map of prior step results, and a `scope`
|
|
32
|
+
* record for forEach/branch-local bindings.
|
|
33
|
+
*
|
|
34
|
+
* `scope` is `Record<string, unknown>` — sub-step builders (forEach,
|
|
35
|
+
* branch) populate it once they land in later M.1 slices. M.1.1 ships
|
|
36
|
+
* with `r.step.return` only, which reads `event` and ignores both
|
|
37
|
+
* `steps` and `scope`.
|
|
38
|
+
*/
|
|
39
|
+
export type PipelineCtx<
|
|
40
|
+
TPayload = unknown,
|
|
41
|
+
TMap extends object = KumikoEventTypeMap,
|
|
42
|
+
> = HandlerContext<TMap> & {
|
|
43
|
+
readonly event: WriteEvent<TPayload>;
|
|
44
|
+
readonly steps: Readonly<Record<string, unknown>>;
|
|
45
|
+
readonly scope: Readonly<Record<string, unknown>>;
|
|
46
|
+
// Workflow-run context — present only when running inside defineWorkflow.
|
|
47
|
+
// Tier-3 steps use this to write suspension events onto the correct
|
|
48
|
+
// aggregate stream and for the Resume-Loop to re-hydrate state.
|
|
49
|
+
readonly workflow?: {
|
|
50
|
+
readonly runId: string;
|
|
51
|
+
readonly workflowName: string;
|
|
52
|
+
/** Current step index in the pipeline — set by the executor before each step. */
|
|
53
|
+
readonly stepIndex: number;
|
|
54
|
+
/**
|
|
55
|
+
* Retry attempt counter for the current workflow.retry step.
|
|
56
|
+
* Set by the workflow-engine resume-loop on re-entry; starts at 1.
|
|
57
|
+
* Absent (undefined) means first attempt.
|
|
58
|
+
*/
|
|
59
|
+
readonly retryAttempt?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Q7 Snapshot-at-Start fingerprint of the workflow definition that
|
|
62
|
+
* started this run. Propagated into every suspension event payload so
|
|
63
|
+
* the resume-loop can detect library-upgrades that changed the closure
|
|
64
|
+
* source and surface them as a typed failure instead of running a
|
|
65
|
+
* stale-vs-new mix of steps. Optional only for legacy callers; the
|
|
66
|
+
* workflow-runner sets it on every newly-started run.
|
|
67
|
+
*/
|
|
68
|
+
readonly definitionFingerprint?: string;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A resolver is either a static value or a function that derives the
|
|
74
|
+
* value from the pipeline-context. M.1 keeps the resolver signature
|
|
75
|
+
* uniform — every step accepts both forms via a normalise helper.
|
|
76
|
+
*/
|
|
77
|
+
export type StepResolver<T, TPayload = unknown> = T | ((ctx: PipelineCtx<TPayload>) => T);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-step error strategy. M.1.1 only supports "throw" — the type is
|
|
81
|
+
* deliberately narrowed so callers cannot pass an unsupported strategy
|
|
82
|
+
* past the type-checker. The doc lists "return" / "skip" / fallback as
|
|
83
|
+
* future strategies; each lands together with its own runtime support
|
|
84
|
+
* + integration test in a later slice (no untested type expansion).
|
|
85
|
+
*/
|
|
86
|
+
export type StepFailureStrategy = "throw";
|
|
87
|
+
|
|
88
|
+
export type StepDef<TArgs = unknown, TResult = unknown> = {
|
|
89
|
+
readonly kind: StepKind;
|
|
90
|
+
readonly defaultFailureStrategy: StepFailureStrategy;
|
|
91
|
+
// Returns the result-key for this step instance, or undefined when the
|
|
92
|
+
// step doesn't surface a result. The first-position name on the call
|
|
93
|
+
// (e.g. r.step.compute("startedAt", fn) → "startedAt") becomes the key.
|
|
94
|
+
readonly resultKey?: (args: TArgs) => string | undefined;
|
|
95
|
+
// Sub-pipeline arg-paths — names of `args.<path>` entries that hold a
|
|
96
|
+
// readonly StepInstance[] (e.g. branch's `["onTrue", "onFalse"]`, forEach's
|
|
97
|
+
// `["do"]`). The boot-validator reads these at registration time so it
|
|
98
|
+
// can recurse into nested pipelines without a hardcoded kind-list. Steps
|
|
99
|
+
// that don't carry sub-pipelines omit the field. Followup #15 self-
|
|
100
|
+
// registration: prevents future sub-step-builders from silently bypassing
|
|
101
|
+
// the unsafeProjection allowlist by forgetting to update a central map.
|
|
102
|
+
readonly subPaths?: readonly string[];
|
|
103
|
+
// Step-vocabulary tier (Q9). Tier-1 implicit, Tier-2+ requires
|
|
104
|
+
// r.requires.step("<kind>") in the owning feature. Default 1 (implicit).
|
|
105
|
+
// Tier-3 is only available inside defineWorkflow.
|
|
106
|
+
readonly tier?: 1 | 2 | 3;
|
|
107
|
+
// Runtime: resolve the args against the ctx, perform the work, return
|
|
108
|
+
// the value to land in steps.{resultKey}. Thrown errors propagate to
|
|
109
|
+
// the dispatcher's catch (M.1.1 supports "throw"-strategy only).
|
|
110
|
+
readonly run: (args: TArgs, ctx: PipelineCtx) => Promise<TResult> | TResult;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* An instance of a step in a pipeline — what users build via the
|
|
115
|
+
* step-builder (`r.step.compute(...)`). Carries the kind + resolved-or-
|
|
116
|
+
* resolver args + the user-chosen onFailure override.
|
|
117
|
+
*
|
|
118
|
+
* `args` is `unknown` at this layer — the registered StepDef knows the
|
|
119
|
+
* concrete shape and casts at run() time. Cross-step type-safety lives
|
|
120
|
+
* in the per-step builder factories, not in this central type.
|
|
121
|
+
*/
|
|
122
|
+
export type StepInstance = {
|
|
123
|
+
readonly kind: StepKind;
|
|
124
|
+
readonly args: unknown;
|
|
125
|
+
readonly onFailure?: StepFailureStrategy;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What `pipeline(closure)` returns. Carries the closure (instead of an
|
|
130
|
+
* eagerly-built array) so each handler-call sees a fresh event ref and
|
|
131
|
+
* the `r` step-builder is resolved at runtime, not at module-load time.
|
|
132
|
+
*
|
|
133
|
+
* `__kind: "pipeline"` lets defineWriteHandler distinguish a pipeline-
|
|
134
|
+
* form `perform` from accidental other shapes.
|
|
135
|
+
*
|
|
136
|
+
* `_TData` is a phantom type-parameter — held in constraint position
|
|
137
|
+
* only, never referenced in the type body. defineWriteHandler binds it
|
|
138
|
+
* via `def.perform: PipelineDef<…, TData>` (the call-site uses TData
|
|
139
|
+
* without underscore — phantom-prefix is purely a Biome
|
|
140
|
+
* `noUnusedVariables` marker, not user-facing). _TData is NOT inferred
|
|
141
|
+
* from the closure body (r.step.return has its own per-call TData), so
|
|
142
|
+
* callers must spell it explicitly:
|
|
143
|
+
* `pipeline<{ greeting: string }, { echoed: string }>(...)`
|
|
144
|
+
* Better DX is a known follow-up — see step-vocabulary.md M.1-Followups.
|
|
145
|
+
*/
|
|
146
|
+
export type PipelineDef<TPayload = unknown, _TData = unknown> = {
|
|
147
|
+
readonly __kind: "pipeline";
|
|
148
|
+
readonly build: (ctx: PipelineBuildCtx<TPayload>) => readonly StepInstance[];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Argument bundle passed to the closure inside `pipeline(closure)`.
|
|
153
|
+
* Build-time only — no `steps`, no `scope`, no `db`: at build time no
|
|
154
|
+
* step has run yet.
|
|
155
|
+
*
|
|
156
|
+
* The closure is invoked ONCE per handler-call and returns the immutable
|
|
157
|
+
* list of step instances. Values inside the closure that depend on prior
|
|
158
|
+
* step results MUST go through resolvers (functions) — those receive the
|
|
159
|
+
* resolver-side PipelineCtx which carries `steps` + `scope`.
|
|
160
|
+
*
|
|
161
|
+
* **Closure-body contract:** the closure must produce a deterministic
|
|
162
|
+
* step-list that doesn't depend on `event.payload` — branching on payload
|
|
163
|
+
* fields belongs inside resolvers (where they fire per-call), not in the
|
|
164
|
+
* outer closure body. Boot-validation runs the closure once with a dummy
|
|
165
|
+
* empty payload to scan unsafeProjection-* step targets; a closure that
|
|
166
|
+
* conditionally builds different step-lists per payload would silently
|
|
167
|
+
* skip validation. See validate-projection-allowlist.ts for the
|
|
168
|
+
* boot-side mechanics.
|
|
169
|
+
*/
|
|
170
|
+
export type PipelineBuildCtx<TPayload = unknown> = {
|
|
171
|
+
readonly event: WriteEvent<TPayload>;
|
|
172
|
+
readonly r: StepBuilder;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Step-builder namespace handed to the pipeline closure. The fields
|
|
177
|
+
* grow as M.1 adds more steps. Each is a thin factory that returns a
|
|
178
|
+
* StepInstance — the runtime resolution happens later in run().
|
|
179
|
+
*
|
|
180
|
+
* Why nested under `step`: matches the doc-API surface and leaves room
|
|
181
|
+
* for future sibling namespaces (`r.trigger`, `r.transform`) without
|
|
182
|
+
* crowding the top-level r.
|
|
183
|
+
*/
|
|
184
|
+
export type StepBuilder = {
|
|
185
|
+
readonly step: StepNamespace;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The collection of step factory functions. Grown incrementally —
|
|
190
|
+
* landed: return (M.1.1), compute (M.1.2), unsafeProjectionUpsert
|
|
191
|
+
* (M.1.3), aggregate.create (M.1.4). Pending: branch, forEach,
|
|
192
|
+
* read.*, aggregate.update, aggregate.appendEvent,
|
|
193
|
+
* unsafeProjectionDelete.
|
|
194
|
+
*/
|
|
195
|
+
export type StepNamespace = {
|
|
196
|
+
readonly return: <TData>(resolver: StepResolver<WriteResult<TData>>) => StepInstance;
|
|
197
|
+
readonly compute: <TResult>(name: string, fn: (ctx: PipelineCtx) => TResult) => StepInstance;
|
|
198
|
+
// Inline read-side projection write. Boot-validation enforces the
|
|
199
|
+
// table is in the owning feature's r.requires.projection allowlist
|
|
200
|
+
// and NOT registered as an aggregate-table via r.entity. See
|
|
201
|
+
// step-vocabulary.md "Was unsafeProjection.* überspringt".
|
|
202
|
+
readonly unsafeProjectionUpsert: (args: {
|
|
203
|
+
readonly table: Table;
|
|
204
|
+
readonly on: readonly string[];
|
|
205
|
+
readonly row: StepResolver<Record<string, unknown>>;
|
|
206
|
+
}) => StepInstance;
|
|
207
|
+
// Sibling: delete row(s) from a read-side projection table. Same
|
|
208
|
+
// boot-validation contract as unsafeProjectionUpsert.
|
|
209
|
+
readonly unsafeProjectionDelete: (args: {
|
|
210
|
+
readonly table: Table;
|
|
211
|
+
readonly where: StepResolver<SQL>;
|
|
212
|
+
}) => StepInstance;
|
|
213
|
+
// Read sub-namespace — thin wrapper on ctx.db.select(). Caller-owned
|
|
214
|
+
// tenant-filter (does NOT auto-inject like ctx.queryProjection does).
|
|
215
|
+
readonly read: {
|
|
216
|
+
readonly findOne: (
|
|
217
|
+
name: string,
|
|
218
|
+
opts: {
|
|
219
|
+
readonly table: Table;
|
|
220
|
+
readonly where: StepResolver<SQL | undefined>;
|
|
221
|
+
},
|
|
222
|
+
) => StepInstance;
|
|
223
|
+
readonly findMany: (
|
|
224
|
+
name: string,
|
|
225
|
+
opts: {
|
|
226
|
+
readonly table: Table;
|
|
227
|
+
readonly where?: StepResolver<SQL | undefined>;
|
|
228
|
+
readonly limit?: number;
|
|
229
|
+
},
|
|
230
|
+
) => StepInstance;
|
|
231
|
+
};
|
|
232
|
+
// Aggregate-mutation sub-namespace — wraps the existing event-store-
|
|
233
|
+
// executor surface. Every method goes through the full ES pipeline
|
|
234
|
+
// (events + projections + lifecycle hooks + audit). The default and
|
|
235
|
+
// intended path for domain mutation; contrast with unsafeProjection.*.
|
|
236
|
+
readonly aggregate: {
|
|
237
|
+
readonly create: (
|
|
238
|
+
name: string,
|
|
239
|
+
opts: {
|
|
240
|
+
readonly executor: EventStoreExecutor;
|
|
241
|
+
readonly data: StepResolver<Record<string, unknown>>;
|
|
242
|
+
},
|
|
243
|
+
) => StepInstance;
|
|
244
|
+
readonly update: (
|
|
245
|
+
name: string,
|
|
246
|
+
opts: {
|
|
247
|
+
readonly executor: EventStoreExecutor;
|
|
248
|
+
readonly id: StepResolver<EntityId>;
|
|
249
|
+
readonly changes: StepResolver<Record<string, unknown>>;
|
|
250
|
+
readonly version?: StepResolver<number | undefined>;
|
|
251
|
+
readonly skipOptimisticLock?: boolean;
|
|
252
|
+
},
|
|
253
|
+
) => StepInstance;
|
|
254
|
+
readonly appendEvent: (args: {
|
|
255
|
+
readonly aggregateId: StepResolver<string>;
|
|
256
|
+
readonly aggregateType: string;
|
|
257
|
+
readonly type: string;
|
|
258
|
+
readonly payload: StepResolver<unknown>;
|
|
259
|
+
readonly headers?: StepResolver<Readonly<Record<string, string | number | boolean>>>;
|
|
260
|
+
}) => StepInstance;
|
|
261
|
+
};
|
|
262
|
+
// Conditional sub-pipeline. `onTrue` (required) and `onFalse`
|
|
263
|
+
// (optional) are static StepInstance arrays; `r` for sub-step builders
|
|
264
|
+
// is captured from the outer pipeline closure. Naming-Q14: `onTrue`/
|
|
265
|
+
// `onFalse` over `then`/`else` because Biome's noThenProperty lint
|
|
266
|
+
// flags `then` as a thenable-trap. Q12: r.step.return inside
|
|
267
|
+
// onTrue/onFalse is rejected at build time (would trigger
|
|
268
|
+
// discriminated-union TData trap). Q13: no resultKey — branch is
|
|
269
|
+
// side-effect-only.
|
|
270
|
+
readonly branch: (args: {
|
|
271
|
+
readonly if: StepResolver<boolean>;
|
|
272
|
+
readonly onTrue: readonly StepInstance[];
|
|
273
|
+
readonly onFalse?: readonly StepInstance[];
|
|
274
|
+
}) => StepInstance;
|
|
275
|
+
// Iterate a sub-pipeline over an array. `as` is required (Q15);
|
|
276
|
+
// current item lands under `scope[as]` for resolvers in `do`.
|
|
277
|
+
// Sequential only in M.1.6; concurrency is Followup #12.
|
|
278
|
+
readonly forEach: <TItem = unknown>(args: {
|
|
279
|
+
readonly over: StepResolver<readonly TItem[]>;
|
|
280
|
+
readonly as: string;
|
|
281
|
+
readonly do: readonly StepInstance[];
|
|
282
|
+
readonly concurrency?: 1;
|
|
283
|
+
}) => StepInstance;
|
|
284
|
+
// Tier-2 namespace. Each builder requires r.requires.step("<kind>")
|
|
285
|
+
// in the owning feature; boot-validation enforces.
|
|
286
|
+
readonly webhook: {
|
|
287
|
+
readonly send: (args: {
|
|
288
|
+
readonly url: StepResolver<string>;
|
|
289
|
+
readonly method?: "POST" | "PUT" | "PATCH";
|
|
290
|
+
readonly headers?: StepResolver<Readonly<Record<string, string>>>;
|
|
291
|
+
readonly body?: StepResolver<unknown>;
|
|
292
|
+
readonly auth?:
|
|
293
|
+
| { readonly kind: "bearer"; readonly secretRef: string }
|
|
294
|
+
| { readonly kind: "header"; readonly name: string; readonly secretRef: string };
|
|
295
|
+
readonly mode: "deferred";
|
|
296
|
+
readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
|
|
297
|
+
}) => StepInstance;
|
|
298
|
+
};
|
|
299
|
+
readonly mail: {
|
|
300
|
+
readonly send: (args: {
|
|
301
|
+
readonly to: StepResolver<string | readonly string[]>;
|
|
302
|
+
readonly subject: StepResolver<string>;
|
|
303
|
+
readonly body: StepResolver<string>;
|
|
304
|
+
readonly from?: StepResolver<string>;
|
|
305
|
+
readonly mode: "deferred";
|
|
306
|
+
}) => StepInstance;
|
|
307
|
+
};
|
|
308
|
+
readonly callFeature: (
|
|
309
|
+
name: string,
|
|
310
|
+
opts: {
|
|
311
|
+
readonly handler: string;
|
|
312
|
+
readonly payload: StepResolver<unknown>;
|
|
313
|
+
readonly as?: import("./handlers").SessionUser;
|
|
314
|
+
},
|
|
315
|
+
) => StepInstance;
|
|
316
|
+
// --- Tier-3 / Workflow-only steps ---
|
|
317
|
+
// Only available inside defineWorkflow ({ steps: pipeline(...) }).
|
|
318
|
+
// Runtime guard: throws when used inside sync defineWriteHandler.
|
|
319
|
+
readonly wait: (args: { readonly for: StepResolver<string> }) => StepInstance;
|
|
320
|
+
readonly waitForEvent: (args: {
|
|
321
|
+
readonly event: string;
|
|
322
|
+
readonly match?: StepResolver<(payload: unknown) => boolean>;
|
|
323
|
+
readonly timeout: StepResolver<string>;
|
|
324
|
+
}) => StepInstance;
|
|
325
|
+
readonly retry: (args: {
|
|
326
|
+
readonly times: number;
|
|
327
|
+
readonly backoff: "exponential" | "linear";
|
|
328
|
+
readonly do: readonly StepInstance[];
|
|
329
|
+
}) => StepInstance;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// SaveContext is the result-type of aggregate.create / aggregate.update;
|
|
333
|
+
// re-exported for step authors who want to type their resolver bindings.
|
|
334
|
+
export type AggregateStepResult = SaveContext;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// TargetRef — runtime-Repräsentation eines typed buildTarget-Outputs.
|
|
2
|
+
// Wird vom Visual-Tree-Component (renderer-web) an einen Target-Resolver
|
|
3
|
+
// dispatcht; der Resolver findet die Editor-Maske via featureId.
|
|
4
|
+
//
|
|
5
|
+
// **Compile-time-Safety:** TargetRef wird niemals hand-getippt. Stattdessen
|
|
6
|
+
// erzeugt der typed buildTarget-Builder (engine/build-target.ts) einen
|
|
7
|
+
// TargetRef, dessen action + args gegen die treeActions-Map des Ziel-
|
|
8
|
+
// Features validiert sind.
|
|
9
|
+
//
|
|
10
|
+
// **Runtime:** args sind hier untyped (Record<string, unknown>), weil
|
|
11
|
+
// TargetRef die erased-runtime-Version ist. Der Resolver kennt das
|
|
12
|
+
// Ziel-Feature und kann args entsprechend casten — ähnlich wie Event-
|
|
13
|
+
// Payloads im Event-Store.
|
|
14
|
+
//
|
|
15
|
+
// Siehe docs/plans/architecture/visual-tree.md A5.
|
|
16
|
+
|
|
17
|
+
export type TargetRef = {
|
|
18
|
+
readonly featureId: string;
|
|
19
|
+
readonly action: string;
|
|
20
|
+
readonly args?: Readonly<Record<string, unknown>>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// TreeNode — single Knoten im Visual-Tree (opt-in Workspace mit
|
|
2
|
+
// `navigation: "tree"`). Provider liefern entweder statische
|
|
3
|
+
// readonly TreeNode[] oder dynamische TreeChildrenSubscribe.
|
|
4
|
+
//
|
|
5
|
+
// **Mental-Modell** (VS-Code-Explorer):
|
|
6
|
+
// [icon] [label] [...hover-actions]
|
|
7
|
+
// optional ein target zum Klicken (öffnet Editor-Maske via
|
|
8
|
+
// Target-Resolver) und optional children als nested tree.
|
|
9
|
+
//
|
|
10
|
+
// **State** markiert Visual-Modus für Skeleton-Pattern:
|
|
11
|
+
// - "filled" (default) — schwarz, Knoten hat Inhalt
|
|
12
|
+
// - "stub" — hellgrau, existing aber leer (Designer-Stub-File)
|
|
13
|
+
// - "empty" — Platzhalter für "+ create"-Affordance
|
|
14
|
+
// - "loading" — Children werden gerade aufgelöst
|
|
15
|
+
// - "error" — Provider hat Fehler emittiert
|
|
16
|
+
// Provider die kein Skeleton-Pattern brauchen müssen state nicht setzen.
|
|
17
|
+
//
|
|
18
|
+
// **Subscribe-Form** für dynamic Children: Provider erhält emit(),
|
|
19
|
+
// gibt unsubscribe() zurück. Initial-Emit synchron oder async, weitere
|
|
20
|
+
// Emits beliebig oft (z.B. wenn Entity-Row neu erscheint via SSE).
|
|
21
|
+
// Spielt natürlich mit existing SSE-Frame: ein Provider kann intern
|
|
22
|
+
// auf Entity-Update-Events abonnieren und bei Änderung emit() aufrufen.
|
|
23
|
+
//
|
|
24
|
+
// Siehe docs/plans/architecture/visual-tree.md A4.
|
|
25
|
+
|
|
26
|
+
import type { TenantId } from "./identifiers";
|
|
27
|
+
import type { TargetRef } from "./target-ref";
|
|
28
|
+
|
|
29
|
+
export type TreeNodeState = "filled" | "stub" | "empty" | "loading" | "error";
|
|
30
|
+
|
|
31
|
+
export type TreeAction = {
|
|
32
|
+
// Icon-Key — vom Renderer-Icon-Registry interpretiert. Konvention
|
|
33
|
+
// matched NavDefinition.icon: unbekannte Icons surface als missing-icon
|
|
34
|
+
// im UI, nicht als Boot-Failure.
|
|
35
|
+
readonly icon: string;
|
|
36
|
+
// i18n-Translation-Key oder roher String. Vom Renderer aufgelöst, Engine
|
|
37
|
+
// behandelt opak (mirrors NavDefinition.label, WorkspaceDefinition.label).
|
|
38
|
+
readonly label: string;
|
|
39
|
+
// Klick-Ziel der Action. Pflicht — Action ohne target ist semantisch
|
|
40
|
+
// sinnlos (Hover-Icon das nichts tut).
|
|
41
|
+
readonly target: TargetRef;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type TreeNode = {
|
|
45
|
+
// i18n-Translation-Key oder roher String. Vom Renderer beim Rendern
|
|
46
|
+
// aufgelöst (siehe TreeAction.label).
|
|
47
|
+
readonly label: string;
|
|
48
|
+
// Optional. Icon links neben dem Label. Selbe Konvention wie
|
|
49
|
+
// TreeAction.icon — Renderer-Icon-Registry-Lookup.
|
|
50
|
+
readonly icon?: string;
|
|
51
|
+
// Visueller State für Skeleton-Pattern. Default "filled" (kein Eintrag
|
|
52
|
+
// ⇒ schwarz/normal). Wert-Semantik im Header-Comment dieser Datei.
|
|
53
|
+
readonly state?: TreeNodeState;
|
|
54
|
+
// Optional Klick-Ziel. Fehlt → reiner Container-Knoten (nur ausklappbar,
|
|
55
|
+
// nicht klickbar). Vorhanden → Klick öffnet die Editor-Maske via
|
|
56
|
+
// Target-Resolver in renderer-web.
|
|
57
|
+
readonly target?: TargetRef;
|
|
58
|
+
// Hover-Actions rechts (Add/Refresh/Delete/etc.). Werden in der
|
|
59
|
+
// Sidebar-Row erst bei Hover sichtbar — VS-Code-Pattern. Engine
|
|
60
|
+
// ordnet die Actions in der Reihenfolge an, in der sie hier stehen.
|
|
61
|
+
readonly actions?: readonly TreeAction[];
|
|
62
|
+
// Statische Children oder dynamic Subscribe-Function. Subscribe wird
|
|
63
|
+
// erst beim Ausklappen aufgerufen (lazy); die Function-Form erlaubt
|
|
64
|
+
// SSE-gefütterte Live-Updates wenn neue Entity-Rows reinkommen.
|
|
65
|
+
readonly children?: readonly TreeNode[] | TreeChildrenSubscribe;
|
|
66
|
+
// Provider-deklarierte „+ create"-Action für Knoten mit `state: "empty"`.
|
|
67
|
+
// Tree-Component zeigt automatisch ein „+"-Icon und dispatcht
|
|
68
|
+
// `createAction.target` bei Klick — Provider weiß was „leer befüllen"
|
|
69
|
+
// für ihn bedeutet (z.B. „neuer Page-Slug" vs „neue Entity-Row"),
|
|
70
|
+
// Convention könnte das nicht raten. Konsistent zu `state` (auch
|
|
71
|
+
// Provider-explizit). Siehe visual-tree.md V.1.1-Decision D3.
|
|
72
|
+
readonly createAction?: TreeAction;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Subscribe<T> — Provider implementiert: emit(initial); ...emit(updated);
|
|
76
|
+
// und gibt unsubscribe-Function zurück. Caller (Tree-Component) ruft
|
|
77
|
+
// unsubscribe auf wenn Knoten unmounted/eingeklappt wird.
|
|
78
|
+
export type Subscribe<T> = (emit: (value: T) => void) => () => void;
|
|
79
|
+
|
|
80
|
+
// TreeChildrenSubscribe — Lazy-Variante für dynamic Children. Wird
|
|
81
|
+
// erst aufgerufen wenn der Knoten im UI ausgeklappt wird. ctx ist
|
|
82
|
+
// für Phase 0 ein opaque empty Type; V.1.1 erweitert ihn um Query-/
|
|
83
|
+
// Subscribe-Helpers (entity-list, slug-list etc.).
|
|
84
|
+
export type TreeChildrenSubscribe = (ctx: TreeContext) => Subscribe<readonly TreeNode[]>;
|
|
85
|
+
|
|
86
|
+
// TreeContext — Provider erhält context-Objekt mit den minimal-nötigen
|
|
87
|
+
// React-Tree-State-Bridges. V.1.1 startet mit `tenantId` only (Provider
|
|
88
|
+
// braucht tenant-awareness sonst stale-tenant-Bug bei Tenant-Switch);
|
|
89
|
+
// `query` und `subscribe` werden additiv ergänzt wenn ein konkreter
|
|
90
|
+
// Konsument sie braucht (V.1.2: text-content slug-list-query, später
|
|
91
|
+
// SSE-driven re-emit). Memory `[Keine Optionen ohne Bedarf]` — surface
|
|
92
|
+
// wächst mit Bedarfen, nicht mit Spekulation. Siehe visual-tree.md
|
|
93
|
+
// V.1.1-Decision D1.
|
|
94
|
+
export type TreeContext = Readonly<{
|
|
95
|
+
readonly tenantId: TenantId;
|
|
96
|
+
}>;
|
|
97
|
+
|
|
98
|
+
// TreeActionDef — Schema-Eintrag pro Action in der treeActions-Map
|
|
99
|
+
// eines Features. Phase 0: Args sind ein optionales Type-Sample
|
|
100
|
+
// (kein Validator zur Laufzeit — Validation passiert compile-time
|
|
101
|
+
// via buildTarget-Generic, runtime via Editor-Panel-Schema).
|
|
102
|
+
//
|
|
103
|
+
// Lebt hier (nicht in build-target.ts) weil es konzeptuell zur
|
|
104
|
+
// Visual-Tree-Domäne gehört, nicht zum Builder. build-target.ts
|
|
105
|
+
// importiert den Type von hier.
|
|
106
|
+
export type TreeActionDef<TArgs = Record<string, unknown>> = {
|
|
107
|
+
readonly args?: TArgs;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// TreeActionsHandle<T> — Return-Type von r.treeActions(...). Trägt
|
|
111
|
+
// den literal-typed Action-Map durch das Feature-Export-System
|
|
112
|
+
// (siehe FeatureDefinition.exports + Memory `[EventDef-Exports-
|
|
113
|
+
// Pattern]`). Das ist die compile-time Bridge zu buildTarget:
|
|
114
|
+
//
|
|
115
|
+
// const handle = r.treeActions({ edit: { args: { slug: "" as string } } });
|
|
116
|
+
// // handle.id → TFeature (literal feature name)
|
|
117
|
+
// // handle.treeActions → { edit: { args: { slug: string } } } (literal-typed)
|
|
118
|
+
// buildTarget({ target: handle, action: "edit", args: { slug: "x" } });
|
|
119
|
+
// // ^^^^^^^^^^^^^^ ^^^^^^^^
|
|
120
|
+
// // literal-validated typed-validated
|
|
121
|
+
//
|
|
122
|
+
// Runtime-Lookup geht über FeatureDefinition.treeActions (erased Map),
|
|
123
|
+
// Compile-Time-Validation über diesen Handle.
|
|
124
|
+
export type TreeActionsHandle<
|
|
125
|
+
TFeature extends string,
|
|
126
|
+
TActions extends Record<string, TreeActionDef>,
|
|
127
|
+
> = {
|
|
128
|
+
readonly id: TFeature;
|
|
129
|
+
readonly treeActions: TActions;
|
|
130
|
+
};
|
|
@@ -39,4 +39,11 @@ export type WorkspaceDefinition = {
|
|
|
39
39
|
// Default workspace at login when the user has access to multiple. Boot
|
|
40
40
|
// validator rejects more than one default per app.
|
|
41
41
|
readonly default?: boolean;
|
|
42
|
+
// Render mode of the workspace's sidebar. "nav" (default, missing-ok)
|
|
43
|
+
// mounts the existing NavTree component as before. "tree" mounts the
|
|
44
|
+
// Visual-Tree component which collects r.tree() providers across all
|
|
45
|
+
// active features. Default-on-undefined preserves backwards-compat —
|
|
46
|
+
// every existing workspace stays nav-mode without code touch.
|
|
47
|
+
// See visual-tree.md A1.
|
|
48
|
+
readonly navigation?: "nav" | "tree";
|
|
42
49
|
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Boot-validation for r.step.unsafeProjection* allowlist.
|
|
2
|
+
//
|
|
3
|
+
// Q10 declared the allowlist a hard requirement: every unsafeProjection*
|
|
4
|
+
// step must target a table that the owning feature explicitly declared
|
|
5
|
+
// via r.requires.projection("table_name"), and that table must NOT be
|
|
6
|
+
// one of the registered aggregate-tables (those are managed via
|
|
7
|
+
// r.entity / r.step.aggregate.*). Boot-error otherwise.
|
|
8
|
+
//
|
|
9
|
+
// Mechanism: each writeHandler that uses the perform-pipeline form ships
|
|
10
|
+
// a closure. We invoke it once at boot with a minimal dummy event to
|
|
11
|
+
// extract the immutable step-list, then walk the list looking for the
|
|
12
|
+
// known unsafeProjection-* kinds. Resolvers (the per-step arg-callbacks)
|
|
13
|
+
// don't run at build time, so dummy event-payloads are fine.
|
|
14
|
+
//
|
|
15
|
+
// CLOSURE-BODY CONTRACT: the pipeline-closure body (the function passed
|
|
16
|
+
// to `pipeline(...)`) must produce its step-list deterministically based
|
|
17
|
+
// on the step-builder `r` alone. Reading `event.payload` outside of
|
|
18
|
+
// resolvers (i.e. at the top of the closure, not inside a step's `row:`
|
|
19
|
+
// or `data:` callback) is forbidden — at boot the dummy payload is `{}`
|
|
20
|
+
// and a closure that branches on payload-fields would pass validation
|
|
21
|
+
// while production calls produce a different step-list. A throw at
|
|
22
|
+
// boot-time is surfaced cleanly; a quietly-different step-list is not
|
|
23
|
+
// caught by this validator. A future lint-rule will enforce the contract
|
|
24
|
+
// statically; today it lives in this comment + the StepBuilder doc.
|
|
25
|
+
|
|
26
|
+
import { getTableName, type Table } from "drizzle-orm";
|
|
27
|
+
import { getStep } from "./define-step";
|
|
28
|
+
import { buildPipelineSteps } from "./pipeline";
|
|
29
|
+
import type { FeatureDefinition, SessionUser, TenantId, WriteEvent } from "./types";
|
|
30
|
+
import type { PipelineDef, StepInstance } from "./types/step";
|
|
31
|
+
|
|
32
|
+
// Listed step-kinds whose `args.table` must be in the owning feature's
|
|
33
|
+
// r.requires.projection allowlist. Extend as further unsafeProjection.*
|
|
34
|
+
// steps land — don't pre-list hypothetical kinds (CLAUDE.md: don't design
|
|
35
|
+
// for scenarios that can't happen).
|
|
36
|
+
const UNSAFE_PROJECTION_KINDS = new Set(["unsafeProjectionUpsert", "unsafeProjectionDelete"]);
|
|
37
|
+
|
|
38
|
+
// Sub-pipeline arg-paths come from `defineStep({ subPaths: [...] })` —
|
|
39
|
+
// each builder declares its own (e.g. branch's onTrue/onFalse, forEach's
|
|
40
|
+
// do). Walking via the registry means nested unsafeProjection-* in NEW
|
|
41
|
+
// sub-step-builders is automatically caught the moment the builder
|
|
42
|
+
// registers itself; no central map to keep in sync. Followup #15.
|
|
43
|
+
function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance, void, void> {
|
|
44
|
+
for (const step of steps) {
|
|
45
|
+
yield step;
|
|
46
|
+
const def = getStep(step.kind);
|
|
47
|
+
const subPaths = def?.subPaths;
|
|
48
|
+
if (!subPaths || subPaths.length === 0) continue;
|
|
49
|
+
const args = step.args as Record<string, unknown>;
|
|
50
|
+
for (const path of subPaths) {
|
|
51
|
+
const subSteps = args[path];
|
|
52
|
+
if (Array.isArray(subSteps)) {
|
|
53
|
+
yield* walkAllSteps(subSteps as readonly StepInstance[]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type UnsafeProjectionStepArgs = { readonly table: Table };
|
|
60
|
+
|
|
61
|
+
const DUMMY_USER: SessionUser = {
|
|
62
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
63
|
+
tenantId: "00000000-0000-0000-0000-000000000000" as TenantId,
|
|
64
|
+
roles: [],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate every feature's pipeline-form writeHandlers against:
|
|
69
|
+
* 1. the owning feature's r.requires.projection allowlist
|
|
70
|
+
* 2. the cross-feature aggregate-table set (registered via r.entity)
|
|
71
|
+
*
|
|
72
|
+
* Throws on the first violation (fail-fast, consistent with other
|
|
73
|
+
* boot-validations).
|
|
74
|
+
*/
|
|
75
|
+
export function validateProjectionAllowlist(features: readonly FeatureDefinition[]): void {
|
|
76
|
+
// Aggregate-tables across all features. Map table-name → owning feature.
|
|
77
|
+
// Two features registering r.entity on the same table is always a bug —
|
|
78
|
+
// physical PG-collision aside, the second feature's writes would replay
|
|
79
|
+
// the first feature's projections (and vice-versa). Detect and surface
|
|
80
|
+
// here so the error names BOTH owners; without this, the silent-set
|
|
81
|
+
// would point any later unsafeProjection-error at the wrong feature.
|
|
82
|
+
// Followup #8.
|
|
83
|
+
const aggregateTables = new Map<string, string>();
|
|
84
|
+
for (const f of features) {
|
|
85
|
+
for (const [entityName, entity] of Object.entries(f.entities)) {
|
|
86
|
+
const tableName = entity.table ?? entityName;
|
|
87
|
+
const existing = aggregateTables.get(tableName);
|
|
88
|
+
if (existing && existing !== f.name) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Aggregate-table "${tableName}" is registered by both feature "${existing}" and feature "${f.name}" via r.entity. ` +
|
|
91
|
+
`Each aggregate-table must have a single owning feature — pick distinct table names ` +
|
|
92
|
+
`(via createEntity({ table: "..." })) or remove the duplicate r.entity registration.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
aggregateTables.set(tableName, f.name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const f of features) {
|
|
100
|
+
for (const [handlerName, handler] of Object.entries(f.writeHandlers)) {
|
|
101
|
+
const perform = (handler as { readonly perform?: PipelineDef }).perform;
|
|
102
|
+
if (!perform || perform.__kind !== "pipeline") continue;
|
|
103
|
+
|
|
104
|
+
let steps: readonly StepInstance[];
|
|
105
|
+
try {
|
|
106
|
+
const dummyEvent: WriteEvent<unknown> = {
|
|
107
|
+
type: handlerName,
|
|
108
|
+
payload: {},
|
|
109
|
+
user: DUMMY_USER,
|
|
110
|
+
};
|
|
111
|
+
steps = buildPipelineSteps(perform, dummyEvent);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[Feature ${f.name}] writeHandler "${handlerName}" pipeline-closure threw at boot: ${message}. ` +
|
|
116
|
+
`Closure body must produce the step list without reading event-payload fields ` +
|
|
117
|
+
`(those belong inside step resolvers).`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const step of walkAllSteps(steps)) {
|
|
122
|
+
// Tier-2 step-discovery (Q9): require explicit opt-in via
|
|
123
|
+
// r.requires.step("<kind>") for any tier-2+ step. Tier-1 is implicit.
|
|
124
|
+
const def = getStep(step.kind);
|
|
125
|
+
if (def?.tier === 2 && !f.requiredSteps.has(step.kind)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[Feature ${f.name}] writeHandler "${handlerName}" uses tier-2 step "${step.kind}" ` +
|
|
128
|
+
`but did not declare it via r.requires.step("${step.kind}"). ` +
|
|
129
|
+
`Add the declaration in defineFeature("${f.name}", r => { r.requires.step("${step.kind}"); ... }).`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!UNSAFE_PROJECTION_KINDS.has(step.kind)) continue;
|
|
133
|
+
const stepArgs = step.args as UnsafeProjectionStepArgs;
|
|
134
|
+
if (!stepArgs.table) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`[Feature ${f.name}] writeHandler "${handlerName}" has a ${step.kind} step ` +
|
|
137
|
+
`without a \`table\` argument.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const tableName = getTableName(stepArgs.table);
|
|
141
|
+
|
|
142
|
+
const aggregateOwner = aggregateTables.get(tableName);
|
|
143
|
+
if (aggregateOwner) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[Feature ${f.name}] writeHandler "${handlerName}" uses ${step.kind} on table "${tableName}", ` +
|
|
146
|
+
`but that table is the aggregate-projection of feature "${aggregateOwner}" (registered via r.entity). ` +
|
|
147
|
+
`Aggregate-tables MUST be mutated through r.step.aggregate.* — see step-vocabulary.md Q10.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!f.requiredProjections.has(tableName)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`[Feature ${f.name}] writeHandler "${handlerName}" uses ${step.kind} on table "${tableName}", ` +
|
|
154
|
+
`but the feature did not declare it via r.requires.projection("${tableName}"). ` +
|
|
155
|
+
`Add the declaration in defineFeature("${f.name}", r => { r.requires.projection("${tableName}"); ... }).`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -136,7 +136,7 @@ export async function loadLatestSnapshot<
|
|
|
136
136
|
tenantId: row.tenantId,
|
|
137
137
|
aggregateType: row.aggregateType,
|
|
138
138
|
version: row.version,
|
|
139
|
-
state: row.state as TState,
|
|
139
|
+
state: row.state as TState, // @cast-boundary engine-payload
|
|
140
140
|
createdAt: row.createdAt,
|
|
141
141
|
};
|
|
142
142
|
}
|