@cosmicdrift/kumiko-types 0.159.1 → 0.161.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/package.json +141 -1
- package/src/concurrency-mode.ts +2 -0
- package/src/config-scope.ts +2 -0
- package/src/config.ts +498 -0
- package/src/cursor-types.ts +18 -0
- package/src/define-handler.ts +115 -0
- package/src/entity-handlers.ts +30 -0
- package/src/entity-table-meta-types.ts +92 -0
- package/src/envelope-cipher-types.ts +6 -0
- package/src/event-store-errors.ts +35 -0
- package/src/event-store-executor-types.ts +93 -0
- package/src/event-store-types.ts +41 -0
- package/src/executor-brand.ts +19 -0
- package/src/feature.ts +1040 -0
- package/src/fields.ts +685 -0
- package/src/file-provider-resolver-types.ts +7 -0
- package/src/file-storage-provider-types.ts +39 -0
- package/src/handlers.ts +836 -0
- package/src/hook-phase.ts +2 -0
- package/src/hooks.ts +170 -0
- package/src/kms-adapter-types.ts +118 -0
- package/src/multi-stream-apply-context-types.ts +38 -0
- package/src/nav.ts +67 -0
- package/src/ownership.ts +83 -0
- package/src/projection.ts +164 -0
- package/src/rate-limit-types.ts +30 -0
- package/src/schema-table-types.ts +24 -0
- package/src/screen.ts +747 -0
- package/src/secrets-types.ts +177 -0
- package/src/snapshot-types.ts +36 -0
- package/src/step.ts +334 -0
- package/src/tenant-db-types.ts +58 -0
- package/src/tz-context.ts +63 -0
- package/src/where-clause-types.ts +25 -0
- package/src/workspace.ts +42 -0
- package/src/write-error-info-types.ts +22 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Envelope Encryption types. Separating DEK (per-value) from KEK (central)
|
|
2
|
+
// is what makes key rotation cheap: on rotation we only re-wrap the small
|
|
3
|
+
// encryptedDek, never touch the ciphertext.
|
|
4
|
+
|
|
5
|
+
import type { TenantId } from "./identifiers";
|
|
6
|
+
|
|
7
|
+
// Plaintext-secret wrapper (branded). Carries the actual string internally
|
|
8
|
+
// but the nominal typing stops it from landing in an HTTP response by
|
|
9
|
+
// accident — a response-serializer guard + the reveal() cost make the leak
|
|
10
|
+
// intentional. Framework code that sees `Secret<string>` knows the caller
|
|
11
|
+
// has already gone through the audited ctx.secrets.get path.
|
|
12
|
+
//
|
|
13
|
+
// The brand is a real (non-registered) Symbol so it exists at runtime for
|
|
14
|
+
// isSecret() without clashing with user-land symbols of the same name.
|
|
15
|
+
const SecretBrand: unique symbol = Symbol("kumiko.secret");
|
|
16
|
+
|
|
17
|
+
export type Secret<T = string> = {
|
|
18
|
+
readonly [SecretBrand]: true;
|
|
19
|
+
readonly reveal: () => T;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Implementation helper — bundled-features uses this to wrap a plaintext after
|
|
23
|
+
// decryption. Kept in the framework so both sides share one canonical brand.
|
|
24
|
+
export function createSecret<T>(value: T): Secret<T> {
|
|
25
|
+
return {
|
|
26
|
+
[SecretBrand]: true as const,
|
|
27
|
+
reveal: () => value,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// True for any object carrying the Secret brand. Used by the response guard
|
|
32
|
+
// to reject leaks before serialization.
|
|
33
|
+
export function isSecret(v: unknown): v is Secret<unknown> {
|
|
34
|
+
return typeof v === "object" && v !== null && SecretBrand in v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Compile-time response guard (R6) --------------------------------------
|
|
38
|
+
//
|
|
39
|
+
// ContainsSecret<T> is `true` only when a Secret<> is DEFINITELY present
|
|
40
|
+
// somewhere in T. The handler-registration guard (defineWriteHandler/
|
|
41
|
+
// defineQueryHandler) turns a `true` into a compile error — the static twin of
|
|
42
|
+
// assertNoSecretLeak's runtime walk.
|
|
43
|
+
//
|
|
44
|
+
// Biased to `false`: anything it cannot inspect — a bare generic type param (a
|
|
45
|
+
// handler generic over its response), `unknown`/`any`, `never` — resolves to
|
|
46
|
+
// `false` = allowed, with the runtime guard as the backstop. The alternative
|
|
47
|
+
// (default-to-leak) false-flags every legitimate generic-over-response handler.
|
|
48
|
+
//
|
|
49
|
+
// Branch order is load-bearing: never/unknown/any first (uninspectable), then
|
|
50
|
+
// Secret, then primitives (covers branded primitives like TenantId without
|
|
51
|
+
// enumerating them), then the SafeLeaf allowlist (opaque class instances that
|
|
52
|
+
// blind `{ [K in keyof T] }` recursion would mangle — the type-level mirror of
|
|
53
|
+
// leak-guard.ts skipping non-plain objects), then arrays, then a "does any
|
|
54
|
+
// field contain a secret" fold over plain objects.
|
|
55
|
+
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
56
|
+
|
|
57
|
+
// Opaque built-in leaves a response legitimately carries; never recurse into
|
|
58
|
+
// them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
|
|
59
|
+
// Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
|
|
60
|
+
// already fell through to `false` via the object-mapped-type branch — listed
|
|
61
|
+
// explicitly here so the compile-time treatment matches leak-guard.ts's
|
|
62
|
+
// runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
|
|
63
|
+
// instead of looking like an oversight.
|
|
64
|
+
type SafeLeaf =
|
|
65
|
+
| Date
|
|
66
|
+
| RegExp
|
|
67
|
+
| Temporal.Instant
|
|
68
|
+
| Temporal.ZonedDateTime
|
|
69
|
+
| Temporal.PlainDate
|
|
70
|
+
| Temporal.PlainDateTime
|
|
71
|
+
| Temporal.PlainTime
|
|
72
|
+
| Temporal.PlainYearMonth
|
|
73
|
+
| Temporal.PlainMonthDay
|
|
74
|
+
| Temporal.Duration
|
|
75
|
+
| Map<unknown, unknown>
|
|
76
|
+
| Set<unknown>;
|
|
77
|
+
|
|
78
|
+
export type ContainsSecret<T> = [T] extends [never]
|
|
79
|
+
? false
|
|
80
|
+
: unknown extends T
|
|
81
|
+
? false
|
|
82
|
+
: T extends Secret<unknown>
|
|
83
|
+
? true
|
|
84
|
+
: T extends Primitive
|
|
85
|
+
? false
|
|
86
|
+
: T extends SafeLeaf
|
|
87
|
+
? false
|
|
88
|
+
: T extends readonly (infer U)[]
|
|
89
|
+
? ContainsSecret<U>
|
|
90
|
+
: T extends object
|
|
91
|
+
? true extends { [K in keyof T]-?: ContainsSecret<T[K]> }[keyof T]
|
|
92
|
+
? true
|
|
93
|
+
: false
|
|
94
|
+
: false;
|
|
95
|
+
|
|
96
|
+
// Per-read audit context. Populated by requireSecretsContext() wrapper so
|
|
97
|
+
// handlers don't need to pass userId/handlerName manually on every call.
|
|
98
|
+
// Undefined for framework-internal reads (rotation job, tests) — the audit
|
|
99
|
+
// table stays a "who touched this credential" log, not a crash-report sink.
|
|
100
|
+
export type SecretAuditContext = {
|
|
101
|
+
readonly userId: string;
|
|
102
|
+
readonly handlerName: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Feature code can pass either the raw qualified-name string or a typed
|
|
106
|
+
// handle returned by r.secret. The handle form is safer — renaming the
|
|
107
|
+
// r.secret call updates all references through the import graph.
|
|
108
|
+
export type SecretKeyRef = string | { readonly name: string };
|
|
109
|
+
|
|
110
|
+
// The ctx.secrets contract. Concrete implementation lives in bundled-features
|
|
111
|
+
// (createSecretsContext) where the DB and MasterKeyProvider are known. This
|
|
112
|
+
// lean interface is what the framework's HandlerContext carries so engine
|
|
113
|
+
// code can talk about it without pulling in bundled-features.
|
|
114
|
+
export interface SecretsContext {
|
|
115
|
+
get(
|
|
116
|
+
tenantId: TenantId,
|
|
117
|
+
key: SecretKeyRef,
|
|
118
|
+
auditCtx?: SecretAuditContext,
|
|
119
|
+
): Promise<Secret<string> | undefined>;
|
|
120
|
+
// Metadata-only existence probe: no decryption, no read-audit event.
|
|
121
|
+
// For readiness checks — use get() when the value itself is needed.
|
|
122
|
+
has(tenantId: TenantId, key: SecretKeyRef): Promise<boolean>;
|
|
123
|
+
set(
|
|
124
|
+
tenantId: TenantId,
|
|
125
|
+
key: SecretKeyRef,
|
|
126
|
+
value: string,
|
|
127
|
+
opts?: { redact?: (plaintext: string) => string; hint?: string; updatedBy?: string },
|
|
128
|
+
): Promise<void>;
|
|
129
|
+
delete(tenantId: TenantId, key: SecretKeyRef, opts?: { deletedBy?: string }): Promise<boolean>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type Envelope = {
|
|
133
|
+
// AES-256-GCM ciphertext of the plaintext, keyed with a DEK.
|
|
134
|
+
readonly ciphertext: Buffer;
|
|
135
|
+
// GCM nonce (12 bytes). Generated fresh per encryption.
|
|
136
|
+
readonly iv: Buffer;
|
|
137
|
+
// GCM auth tag (16 bytes). Guarantees the ciphertext wasn't tampered.
|
|
138
|
+
readonly authTag: Buffer;
|
|
139
|
+
// DEK wrapped with the current KEK. Decryption needs provider.unwrapDek
|
|
140
|
+
// with the kekVersion to recover the DEK.
|
|
141
|
+
readonly encryptedDek: Buffer;
|
|
142
|
+
// Which KEK version was used to wrap the DEK. On rotation, rows with old
|
|
143
|
+
// versions still decrypt — the provider keeps a keyring of historical KEKs.
|
|
144
|
+
readonly kekVersion: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// BYOK hook: callers pass the tenant a value belongs to; a per-tenant-KMS
|
|
148
|
+
// provider keys its wrap/unwrap on it. EnvMasterKeyProvider (app-wide
|
|
149
|
+
// keyring) ignores it — the param exists so the contract doesn't have to
|
|
150
|
+
// break when a tenant-scoped provider ships.
|
|
151
|
+
export type KeyScope = {
|
|
152
|
+
readonly tenantId?: TenantId;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// The contract a KEK backend must fulfil. The framework sees only this
|
|
156
|
+
// interface; concrete implementations live in separate packages
|
|
157
|
+
// (@cosmicdrift/kumiko-secrets-vault, @cosmicdrift/kumiko-secrets-aws-kms, ...). The default is
|
|
158
|
+
// EnvMasterKeyProvider which reads keys from environment variables.
|
|
159
|
+
export interface MasterKeyProvider {
|
|
160
|
+
// Wrap a fresh DEK with the current KEK. Returns the wrapped bytes + the
|
|
161
|
+
// KEK version used — the version ends up in the Envelope so decryption
|
|
162
|
+
// later knows which KEK to ask for.
|
|
163
|
+
wrapDek(dek: Buffer, scope?: KeyScope): Promise<{ encryptedDek: Buffer; kekVersion: number }>;
|
|
164
|
+
|
|
165
|
+
// Unwrap a previously-wrapped DEK. During rotation the provider must
|
|
166
|
+
// accept older kekVersion values (2-version window minimum), otherwise
|
|
167
|
+
// old rows become unreadable.
|
|
168
|
+
unwrapDek(encryptedDek: Buffer, kekVersion: number, scope?: KeyScope): Promise<Buffer>;
|
|
169
|
+
|
|
170
|
+
// Which KEK version new wraps use. Rotation flips this to a new value
|
|
171
|
+
// and older-version reads continue to work until rows are re-wrapped.
|
|
172
|
+
currentVersion(): number;
|
|
173
|
+
|
|
174
|
+
// Health check: can the provider talk to its backend? Used by
|
|
175
|
+
// /health/ready. Cheap probe, no KEK material read.
|
|
176
|
+
isAvailable(): Promise<boolean>;
|
|
177
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StoredEvent } from "./event-store-types";
|
|
2
|
+
|
|
3
|
+
// Reducer used to fold events onto a state. Kept narrow and pure — the
|
|
4
|
+
// caller supplies the shape and update rules. Mirrors the reducer shape
|
|
5
|
+
// feature authors already write for r.projection.apply.
|
|
6
|
+
export type SnapshotReducer<TState extends Record<string, unknown>> = (
|
|
7
|
+
state: TState,
|
|
8
|
+
event: StoredEvent,
|
|
9
|
+
) => TState;
|
|
10
|
+
|
|
11
|
+
export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
|
|
12
|
+
readonly state: TState;
|
|
13
|
+
readonly version: number;
|
|
14
|
+
readonly snapshotHit: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LoadAggregateWithSnapshotOptions = {
|
|
18
|
+
// Opt-in: include archived streams in the rehydrate. Default false — same
|
|
19
|
+
// semantics as loadAggregate / loadAggregateAsOf. Archive check is a
|
|
20
|
+
// single indexed lookup, so the cost stays negligible on the hot path.
|
|
21
|
+
readonly includeArchived?: boolean;
|
|
22
|
+
// Optional upcaster step: every delta event goes through this transform
|
|
23
|
+
// BEFORE the reducer sees it. The dispatcher wires this up with
|
|
24
|
+
// r.eventMigration so feature code always sees current-version payloads.
|
|
25
|
+
// Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
|
|
26
|
+
readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
|
|
27
|
+
// Auto-snapshot policy: when the fold applied at least this many delta
|
|
28
|
+
// events, persist a fresh snapshot at the folded version (best-effort —
|
|
29
|
+
// a failed save never fails the load). Omit to keep snapshotting manual.
|
|
30
|
+
readonly snapshotEvery?: number;
|
|
31
|
+
// Reducer-shape generation stamped onto saved snapshots (default 1). A
|
|
32
|
+
// stored snapshot with a different generation is ignored — full replay
|
|
33
|
+
// through the upcaster chain — and restamped on the next auto-save. Bump
|
|
34
|
+
// whenever the reducer's state shape changes.
|
|
35
|
+
readonly snapshotVersion?: number;
|
|
36
|
+
};
|
package/src/step.ts
ADDED
|
@@ -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 { EventStoreExecutor } from "./event-store-executor-types";
|
|
13
|
+
import type { KumikoEventTypeMap } from "./event-type-map";
|
|
14
|
+
import type { HandlerContext, WriteEvent, WriteResult } from "./handlers";
|
|
15
|
+
import type { SaveContext } from "./hooks";
|
|
16
|
+
import type { EntityId } from "./identifiers";
|
|
17
|
+
import type { WhereObject } from "./where-clause-types";
|
|
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 `stepsPipeline(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
|
+
* `stepsPipeline<{ 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 `stepsPipeline(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: unknown;
|
|
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: unknown;
|
|
211
|
+
readonly where: StepResolver<WhereObject>;
|
|
212
|
+
}) => StepInstance;
|
|
213
|
+
// Read sub-namespace — thin wrapper on selectMany/fetchOne (bun-db).
|
|
214
|
+
// Caller-owned tenant-filter (does NOT auto-inject like ctx.queryProjection does).
|
|
215
|
+
readonly read: {
|
|
216
|
+
readonly findOne: (
|
|
217
|
+
name: string,
|
|
218
|
+
opts: {
|
|
219
|
+
readonly table: unknown;
|
|
220
|
+
readonly where: StepResolver<WhereObject | undefined>;
|
|
221
|
+
},
|
|
222
|
+
) => StepInstance;
|
|
223
|
+
readonly findMany: (
|
|
224
|
+
name: string,
|
|
225
|
+
opts: {
|
|
226
|
+
readonly table: unknown;
|
|
227
|
+
readonly where?: StepResolver<WhereObject | 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: stepsPipeline(...) }).
|
|
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,58 @@
|
|
|
1
|
+
import type { DbRunner } from "./db-connection";
|
|
2
|
+
import type { EntityTableMeta } from "./entity-table-meta-types";
|
|
3
|
+
import type { NotExecutorOnly } from "./executor-brand";
|
|
4
|
+
import type { TenantId } from "./identifiers";
|
|
5
|
+
import type { SchemaTable } from "./schema-table-types";
|
|
6
|
+
import type { SelectOptions, WhereObject } from "./where-clause-types";
|
|
7
|
+
|
|
8
|
+
// Method-form writes reject the executor-only brand exactly like the free-function
|
|
9
|
+
// helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
|
|
10
|
+
// directly — free-function OR method-form — drifts the row past its event stream and
|
|
11
|
+
// a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
|
|
12
|
+
// metas are not projections → writable); `& NotExecutorOnly` strips only branded
|
|
13
|
+
// EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
|
|
14
|
+
// the plain `SchemaTable` param.
|
|
15
|
+
type WritableTable = (SchemaTable | EntityTableMeta) & NotExecutorOnly;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* TenantDb scope modes:
|
|
19
|
+
*
|
|
20
|
+
* - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
|
|
21
|
+
* INSERT forces tenantId — handler cannot override.
|
|
22
|
+
*
|
|
23
|
+
* - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
|
|
24
|
+
* INSERT uses tenantId as default but handler can override.
|
|
25
|
+
*
|
|
26
|
+
* Tables without a tenantId column are always unfiltered regardless of mode.
|
|
27
|
+
*/
|
|
28
|
+
export type TenantDbMode = "tenant" | "system";
|
|
29
|
+
|
|
30
|
+
export type TenantDb = {
|
|
31
|
+
readonly tenantId: TenantId;
|
|
32
|
+
readonly mode: TenantDbMode;
|
|
33
|
+
/**
|
|
34
|
+
* Underlying DbRunner. Framework-internal use (event-store, migrations) —
|
|
35
|
+
* bypasses tenant-filter. Feature code uses the typed helpers above so the
|
|
36
|
+
* automatic scoping stays intact.
|
|
37
|
+
*/
|
|
38
|
+
readonly raw: DbRunner;
|
|
39
|
+
selectMany<T = Record<string, unknown>>(
|
|
40
|
+
table: SchemaTable,
|
|
41
|
+
where?: WhereObject,
|
|
42
|
+
options?: SelectOptions,
|
|
43
|
+
): Promise<readonly T[]>;
|
|
44
|
+
fetchOne<T = Record<string, unknown>>(
|
|
45
|
+
table: SchemaTable,
|
|
46
|
+
where: WhereObject,
|
|
47
|
+
): Promise<T | undefined>;
|
|
48
|
+
insertOne<T = Record<string, unknown>>(
|
|
49
|
+
table: WritableTable,
|
|
50
|
+
values: Record<string, unknown>,
|
|
51
|
+
): Promise<T | undefined>;
|
|
52
|
+
updateMany<T = Record<string, unknown>>(
|
|
53
|
+
table: WritableTable,
|
|
54
|
+
set: Record<string, unknown>,
|
|
55
|
+
where: WhereObject,
|
|
56
|
+
): Promise<readonly T[]>;
|
|
57
|
+
deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
|
|
58
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// TzContext — the type contract for ctx.tz (pure types; the factory lives in
|
|
2
|
+
// @cosmicdrift/kumiko-framework, time/tz-context.ts).
|
|
3
|
+
//
|
|
4
|
+
// `Temporal` here is the ambient global from TypeScript's lib
|
|
5
|
+
// (lib.esnext.temporal) — at runtime the framework installs the polyfill.
|
|
6
|
+
|
|
7
|
+
import type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz";
|
|
8
|
+
|
|
9
|
+
// JSON form for wall-clock + TZ — see createLocatedTimestampField() in
|
|
10
|
+
// engine/factories.ts. Two fields, foolproof.
|
|
11
|
+
export type LocatedTimestampJson = {
|
|
12
|
+
/** Wall-clock ISO without offset, e.g. "2026-04-03T10:00:00" */
|
|
13
|
+
readonly at: string;
|
|
14
|
+
/** IANA zone, e.g. "Europe/Lisbon" */
|
|
15
|
+
readonly tz: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TzContext = {
|
|
19
|
+
/** Default TZ of the tenant (from tenant.timezone, default "UTC"). */
|
|
20
|
+
readonly tenant: string;
|
|
21
|
+
/** Display TZ of the current user (profile override, fallback tenant). */
|
|
22
|
+
readonly user: string;
|
|
23
|
+
|
|
24
|
+
/** Current moment as UTC instant. */
|
|
25
|
+
now(): Temporal.Instant;
|
|
26
|
+
/** Current moment as ZonedDateTime in the requested zone. */
|
|
27
|
+
nowIn(tz: string): Temporal.ZonedDateTime;
|
|
28
|
+
|
|
29
|
+
/** Today's calendar date in the requested zone. */
|
|
30
|
+
today(tz: string): Temporal.PlainDate;
|
|
31
|
+
/** Day boundaries (00:00 to 24:00 next day) as UTC instants — for DB range queries. */
|
|
32
|
+
todayRange(tz: string): { readonly start: Temporal.Instant; readonly end: Temporal.Instant };
|
|
33
|
+
|
|
34
|
+
/** Wall-clock string + IANA zone → ZonedDateTime. */
|
|
35
|
+
parse(wallClock: string, tz: string): Temporal.ZonedDateTime;
|
|
36
|
+
|
|
37
|
+
/** ZonedDateTime → UTC instant. */
|
|
38
|
+
toInstant(zdt: Temporal.ZonedDateTime): Temporal.Instant;
|
|
39
|
+
|
|
40
|
+
/** ZonedDateTime → JSON pair { at, tz } (API boundary). */
|
|
41
|
+
toLocatedJson(zdt: Temporal.ZonedDateTime): LocatedTimestampJson;
|
|
42
|
+
|
|
43
|
+
/** JSON pair { at, tz } → ZonedDateTime (wall-clock + IANA). */
|
|
44
|
+
fromLocatedJson(obj: LocatedTimestampJson): Temporal.ZonedDateTime;
|
|
45
|
+
|
|
46
|
+
/** Geo coordinates → IANA zone via the configured GeoTzProvider.
|
|
47
|
+
* Throws when no provider is configured (v1 default). */
|
|
48
|
+
fromCoordinates(coords: GeoCoordinates): Promise<string>;
|
|
49
|
+
/** Postal address → IANA zone via GeoTzProvider. Throws when no provider is
|
|
50
|
+
* configured OR the provider does not support fromAddress (the offline
|
|
51
|
+
* lat/lng provider does not). */
|
|
52
|
+
fromAddress(address: GeoAddress): Promise<string>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TzContextOptions = {
|
|
56
|
+
/** Tenant default TZ. Default "UTC" when unset. */
|
|
57
|
+
readonly tenant?: string;
|
|
58
|
+
/** User override. Default = tenant. */
|
|
59
|
+
readonly user?: string;
|
|
60
|
+
/** Optional geo→zone adapter for ctx.tz.fromCoordinates / fromAddress.
|
|
61
|
+
* Without a provider those methods throw. */
|
|
62
|
+
readonly geoTz?: GeoTzProvider;
|
|
63
|
+
};
|