@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.
@@ -0,0 +1,2 @@
1
+ // Keep in sync with HookPhases in packages/framework/src/engine/hook-helpers.ts.
2
+ export type HookPhase = "inTransaction" | "afterCommit";
package/src/hooks.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { StoredEvent } from "./event-store-types";
2
+ import type { AppContext } from "./handlers";
3
+ import type { HookPhase } from "./hook-phase";
4
+ import type { EntityId } from "./identifiers";
5
+
6
+ // --- Validation ---
7
+
8
+ export type ValidationError = {
9
+ readonly field: string;
10
+ readonly error: string;
11
+ };
12
+
13
+ export type ValidationHookFn = (
14
+ data: Readonly<Record<string, unknown>>,
15
+ ) => readonly ValidationError[] | null;
16
+
17
+ // --- Save/Delete Context (what hooks receive) ---
18
+
19
+ export type SaveContext = {
20
+ readonly kind: "save";
21
+ readonly id: EntityId;
22
+ readonly data: Readonly<Record<string, unknown>>;
23
+ readonly changes: Readonly<Record<string, unknown>>;
24
+ readonly previous: Readonly<Record<string, unknown>>;
25
+ readonly isNew: boolean;
26
+ readonly entityName?: string | undefined;
27
+ // The event that produced this save. Populated by the event-store-executor;
28
+ // the pipeline uses it to drive projections inside the same transaction.
29
+ // Optional because hand-crafted SaveContexts (tests, custom executors) may
30
+ // not have an event — projections just skip in that case.
31
+ readonly event?: StoredEvent | undefined;
32
+ };
33
+
34
+ export type DeleteContext = {
35
+ readonly kind: "delete";
36
+ readonly id: EntityId;
37
+ readonly data: Readonly<Record<string, unknown>>;
38
+ readonly entityName?: string | undefined;
39
+ // See SaveContext.event — same semantics.
40
+ readonly event?: StoredEvent | undefined;
41
+ };
42
+
43
+ export type LifecycleResult = SaveContext | DeleteContext;
44
+
45
+ // --- Lifecycle Hooks ---
46
+
47
+ export type PreSaveHookFn = (
48
+ changes: Record<string, unknown>,
49
+ context: AppContext & {
50
+ readonly previous: Readonly<Record<string, unknown>>;
51
+ readonly isNew: boolean;
52
+ },
53
+ ) => Promise<Record<string, unknown>>;
54
+
55
+ export type PostSaveHookFn = (result: SaveContext, context: AppContext) => Promise<void>;
56
+
57
+ // Batch-variant: called once at the end of a dispatcher batch with every
58
+ // successful SaveContext. The per-save PostSaveHookFn still fires for
59
+ // side-effects that need per-entity semantics (SSE); PostSaveBatch exists
60
+ // for adapters that can amortise work across the whole batch (e.g. search
61
+ // index batch-writes, bulk webhook fanout).
62
+ export type PostSaveBatchHookFn = (
63
+ results: readonly SaveContext[],
64
+ context: AppContext,
65
+ ) => Promise<void>;
66
+
67
+ export type PreDeleteHookFn = (payload: DeleteContext, context: AppContext) => Promise<void>;
68
+
69
+ export type PostDeleteHookFn = (payload: DeleteContext, context: AppContext) => Promise<void>;
70
+
71
+ export type PostDeleteBatchHookFn = (
72
+ payloads: readonly DeleteContext[],
73
+ context: AppContext,
74
+ ) => Promise<void>;
75
+
76
+ export type PreQueryHookFn = (
77
+ payload: Record<string, unknown>,
78
+ context: AppContext,
79
+ ) => Promise<Record<string, unknown>>;
80
+
81
+ // postQuery — fires after query-handler-execute, before field-access-read-filter.
82
+ // Hook receives normalized rows + entityName + can mutate rows (e.g., merge
83
+ // custom-fields, add computed-counts, attach related-data). Mutation result
84
+ // replaces original rows. Hook is responsible for its own field-access-logic
85
+ // on added fields (field-access-filter only knows entity's stammfields).
86
+ export type PostQueryHookFn = (
87
+ result: {
88
+ // undefined for standalone queries (no-colon handler names like
89
+ // "ns:dashboard") — those have no backing entity, but handler-keyed
90
+ // postQuery hooks still fire on them.
91
+ readonly entityName: string | undefined;
92
+ readonly rows: ReadonlyArray<Record<string, unknown>>;
93
+ },
94
+ context: AppContext,
95
+ ) => Promise<{ rows: ReadonlyArray<Record<string, unknown>> }>;
96
+
97
+ export type LifecycleHookFn =
98
+ | PreSaveHookFn
99
+ | PostSaveHookFn
100
+ | PreDeleteHookFn
101
+ | PostDeleteHookFn
102
+ | PreQueryHookFn
103
+ | PostQueryHookFn;
104
+
105
+ export type { HookPhase } from "./hook-phase";
106
+
107
+ // Owner-tag shared across every hook structure. The lifecycle pipeline uses
108
+ // it to skip hooks whose owning feature is globally disabled:
109
+ // - A concrete feature name like "orders" → subject to the feature-toggle
110
+ // filter (skipped when "orders" is disabled).
111
+ // - "*" (star) → invariant plumbing, never filtered. Reserved for
112
+ // extension-provided hooks and framework-internal hooks that belong to
113
+ // the pipeline itself, not a feature.
114
+ // - Omitted (undefined) → treated as "*". Supports tests that hand-build
115
+ // HookMap objects without caring about ownership.
116
+ export type HookOwner = { readonly featureName?: string };
117
+
118
+ export type PhasedHook<TFn> = {
119
+ readonly fn: TFn;
120
+ readonly phase: HookPhase;
121
+ } & HookOwner;
122
+
123
+ // Flat (non-phased) hook — preSave, preQuery. Same owner contract, no
124
+ // phase semantics because these hooks run exactly once per handler pass
125
+ // before/around the DB transaction.
126
+ export type OwnedFn<TFn> = {
127
+ readonly fn: TFn;
128
+ } & HookOwner;
129
+
130
+ // --- Hook Maps ---
131
+
132
+ // Slots are optional: defineFeature materializes every slot, but hand-built
133
+ // FeatureDefinitions at system boundaries (test fixtures, partial boots —
134
+ // see registry.test.ts "slot robustness") legitimately omit them, and the
135
+ // registry merge paths tolerate undefined. The type mirrors that contract.
136
+ export type HookMap = {
137
+ readonly validation?: Readonly<Record<string, ValidationHookFn>>;
138
+ readonly preSave?: Readonly<Record<string, readonly OwnedFn<PreSaveHookFn>[]>>;
139
+ readonly postSave?: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
140
+ readonly preDelete?: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
141
+ readonly postDelete?: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
142
+ readonly preQuery?: Readonly<Record<string, readonly OwnedFn<PreQueryHookFn>[]>>;
143
+ readonly postQuery?: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
144
+ };
145
+
146
+ export type EntityHookMap = {
147
+ readonly postSave?: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
148
+ readonly preDelete?: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
149
+ readonly postDelete?: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
150
+ readonly postQuery?: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
151
+ };
152
+
153
+ // Search-Payload-Extension (F3) — contributor function that adds flat
154
+ // fields to an entity's search-document. Fires synchronously during
155
+ // buildSearchDocument (in `system-hooks.ts`), receives current entity
156
+ // state, returns extra fields to merge into the search-index payload.
157
+ //
158
+ // Use-cases: custom-fields-bundle (merge customFields-jsonb-keys flat
159
+ // into index), tags-bundle (project tags-array as searchable), computed-
160
+ // fields (denormalize related-counts).
161
+ //
162
+ // IMPORTANT: contributor must be deterministic per (entityName, entityId,
163
+ // state). Async-allowed for future-proofing but discouraged — the
164
+ // indexing path runs once per entity-write, sync extension is
165
+ // near-zero-cost.
166
+ export type SearchPayloadContributorFn = (args: {
167
+ readonly entityName: string;
168
+ readonly entityId: EntityId;
169
+ readonly state: Record<string, unknown>;
170
+ }) => Record<string, unknown> | Promise<Record<string, unknown>>;
@@ -0,0 +1,118 @@
1
+ import type { TenantId } from "./identifiers";
2
+
3
+ // The subject a DEK belongs to. User data is shredded on user-forget,
4
+ // tenant data on tenant-destroy — two erase triggers, two subject kinds.
5
+ export type SubjectId =
6
+ | { readonly kind: "user"; readonly userId: string }
7
+ | { readonly kind: "tenant"; readonly tenantId: TenantId };
8
+
9
+ // Compact storage key ("user:<uuid>" / "tenant:<uuid>") — primary key in
10
+ // adapter backends and cache key in the request-level DEK cache.
11
+ export type SubjectKey = string;
12
+
13
+ export function subjectKeyForUser(userId: string): SubjectKey {
14
+ return `user:${userId}`;
15
+ }
16
+
17
+ export function subjectKeyForTenant(tenantId: TenantId): SubjectKey {
18
+ return `tenant:${tenantId}`;
19
+ }
20
+
21
+ export function subjectIdToKey(subject: SubjectId): SubjectKey {
22
+ return subject.kind === "user"
23
+ ? subjectKeyForUser(subject.userId)
24
+ : subjectKeyForTenant(subject.tenantId);
25
+ }
26
+
27
+ export function subjectIdFromKey(key: SubjectKey): SubjectId {
28
+ if (key.startsWith("user:")) return { kind: "user", userId: key.slice("user:".length) };
29
+ if (key.startsWith("tenant:")) {
30
+ return { kind: "tenant", tenantId: key.slice("tenant:".length) as TenantId }; // @cast-boundary parse of a key this module minted
31
+ }
32
+ throw new Error(`Invalid subject key: ${key}`);
33
+ }
34
+
35
+ export interface KmsContext {
36
+ readonly tenantId?: TenantId;
37
+ readonly requestId: string;
38
+ readonly userId?: string;
39
+ readonly eraseReason?: string;
40
+ }
41
+
42
+ export interface KmsHealth {
43
+ readonly ok: boolean;
44
+ readonly latencyMs: number;
45
+ readonly details?: Record<string, unknown>;
46
+ }
47
+
48
+ // 32-byte AES-256 data-encryption key, unwrapped and ready for local use.
49
+ export type SubjectDek = Buffer;
50
+
51
+ interface KmsAdapterBase {
52
+ /**
53
+ * Creates a fresh subject key. Throws KeyAlreadyExistsError when the
54
+ * subject already has one — including an erased tombstone: a shredded
55
+ * subject must never get a new key, or forget could be undone by
56
+ * re-encrypting under it.
57
+ */
58
+ createKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
59
+
60
+ /**
61
+ * Erases the key material immediately; the tombstone row stays for the
62
+ * audit trail. Idempotent — repeat calls and unknown subjects are no-ops.
63
+ */
64
+ eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
65
+
66
+ /** Probe for boot + readiness. Throws when the backend is unreachable. */
67
+ health(): Promise<KmsHealth>;
68
+ }
69
+
70
+ // Backends that hand out the plaintext DEK (Pg, InMemory). Encrypt/decrypt
71
+ // happens locally; DEKs are cacheable per request.
72
+ export interface LocalKeyKmsAdapter extends KmsAdapterBase {
73
+ readonly capabilities: { readonly mode: "local-key" };
74
+
75
+ /**
76
+ * Throws KeyErasedError after eraseKey (callers render "[[erased]]"),
77
+ * KeyNotFoundError when the subject never had a key (typically a bug).
78
+ */
79
+ getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
80
+ }
81
+
82
+ // Backends that never release key material (Vault transit, cloud KMS).
83
+ // Every encrypt/decrypt is a round-trip; nothing is cacheable.
84
+ export interface RemoteCryptoKmsAdapter extends KmsAdapterBase {
85
+ readonly capabilities: { readonly mode: "remote-crypto" };
86
+
87
+ encrypt(subject: SubjectId, plaintext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
88
+
89
+ /** Same error contract as LocalKeyKmsAdapter.getKey. */
90
+ decrypt(subject: SubjectId, ciphertext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
91
+ }
92
+
93
+ export type KmsAdapter = LocalKeyKmsAdapter | RemoteCryptoKmsAdapter;
94
+
95
+ export function isLocalKeyKmsAdapter(adapter: KmsAdapter): adapter is LocalKeyKmsAdapter {
96
+ return adapter.capabilities.mode === "local-key";
97
+ }
98
+
99
+ export class KeyErasedError extends Error {
100
+ constructor(public readonly subject: SubjectId) {
101
+ super(`Subject key erased: ${subjectIdToKey(subject)}`);
102
+ this.name = "KeyErasedError";
103
+ }
104
+ }
105
+
106
+ export class KeyNotFoundError extends Error {
107
+ constructor(public readonly subject: SubjectId) {
108
+ super(`Subject key not found: ${subjectIdToKey(subject)}`);
109
+ this.name = "KeyNotFoundError";
110
+ }
111
+ }
112
+
113
+ export class KeyAlreadyExistsError extends Error {
114
+ constructor(public readonly subject: SubjectId) {
115
+ super(`Subject key already exists: ${subjectIdToKey(subject)}`);
116
+ this.name = "KeyAlreadyExistsError";
117
+ }
118
+ }
@@ -0,0 +1,38 @@
1
+ import type { StoredEvent } from "./event-store-types";
2
+ import type { KumikoEventTypeMap } from "./event-type-map";
3
+ import type { FileContext } from "./file-handle-types";
4
+ import type { AppendEventFn, UnsafeAppendEventFn } from "./handlers";
5
+
6
+ // Minimal, read+write surface handed to a MultiStreamProjection's apply()
7
+ // when it needs to produce follow-up events (saga / process-manager
8
+ // pattern). Keeps the MSP feature-decoupled: applies don't reach into
9
+ // handler-bridge (no query/write/writeAs), they just read the aggregate
10
+ // stream and append new events — Marten's session scope for projections.
11
+ //
12
+ // TMap propagates the strict event-type-map (see HandlerContext). Default
13
+ // matches the global KumikoEventTypeMap; runtime-pluggable callers route
14
+ // through unsafeAppendEvent.
15
+ export type MultiStreamApplyContext<TMap extends object = KumikoEventTypeMap> = {
16
+ // Append a domain event onto an aggregate stream in the CURRENT tx.
17
+ // Schema-validated, archive-guarded, stream-version derived. Metadata
18
+ // inherits from the triggering event (correlationId) + requestContext
19
+ // (causationId is already set to the triggering event.id by the
20
+ // dispatcher wrap). Strict against KumikoEventTypeMap — same contract
21
+ // as HandlerContext.appendEvent (compile-time-validated payload).
22
+ readonly appendEvent: AppendEventFn<TMap>;
23
+ // Escape hatch for runtime-pluggable events without compile-time
24
+ // augmentation. Same runtime semantics; type-surface is `payload: unknown`.
25
+ readonly unsafeAppendEvent: UnsafeAppendEventFn;
26
+ // Read an aggregate stream — useful when a saga needs to inspect the
27
+ // current state of a different aggregate before deciding what to emit.
28
+ readonly loadAggregate: (
29
+ aggregateId: string,
30
+ options?: { readonly asOf?: Temporal.Instant },
31
+ ) => Promise<readonly StoredEvent[]>;
32
+ // Binary storage handle factory, mirrors AppContext.files. Present when
33
+ // the app booted with `files.storageProvider`; undefined otherwise.
34
+ // Post-processing MSPs (resize, EXIF-strip, virus-scan) read bytes via
35
+ // `ctx.files.ref(payload.storageKey).read()` and write derivates via
36
+ // `.derive("thumb").write(...)` — binaries never ride through events.
37
+ readonly files?: FileContext;
38
+ };
package/src/nav.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { AccessRule } from "./handlers";
2
+ import type { TargetRef } from "./target-ref";
3
+ import type { TreeAction } from "./tree-node";
4
+
5
+ // Nav entry declaration. Every feature that wants to appear in the app's
6
+ // navigation tree registers one or more entries via r.nav(). The engine
7
+ // keeps the list flat — ui-core's resolveNavigation assembles the parent/
8
+ // child tree at render time, so changes (toggles, access-gating) don't
9
+ // require re-indexing a tree shape server-side.
10
+ //
11
+ // Cross-feature references are allowed: `screen` may point at any
12
+ // registered screen QN, `parent` at any registered nav QN. The boot
13
+ // validator checks both references exist + rejects parent cycles.
14
+ export type NavDefinition = {
15
+ // Feature author writes the feature-local short id ("catalog"); the
16
+ // registry overwrites `id` with the qualified name ("shop:nav:catalog")
17
+ // in its stored copy. Callers of `registry.getNav(qn)` /
18
+ // `getTopLevelNavs()` / `getNavsByParent(...)` always see the qualified
19
+ // id — no parallel reverse index needed. `feature.navs[shortId]` on the
20
+ // unregistered FeatureDefinition keeps the short form.
21
+ readonly id: string;
22
+ // i18n translation key. Resolved at render time by the renderer's
23
+ // useTranslation hook; engine keeps it opaque.
24
+ readonly label: string;
25
+ // Icon key — whatever the icon registry of the active renderer understands.
26
+ // Engine doesn't validate; unknown icons surface as a missing icon on screen,
27
+ // not a boot failure.
28
+ readonly icon?: string;
29
+ // Qualified name of a parent nav entry ("<feature>:nav:<id>"). Omit for
30
+ // top-level entries. Boot-validator rejects cycles + dangling refs.
31
+ readonly parent?: string;
32
+ // Sort weight within the parent's children (lower = earlier). Ties are
33
+ // broken by registration order — features registered later appear lower.
34
+ readonly order?: number;
35
+ // Qualified name of the screen this entry navigates to
36
+ // ("<feature>:screen:<id>"). Omit for pure grouping entries (a parent-only
37
+ // nav node that renders a sub-tree but has no target screen itself).
38
+ readonly screen?: string;
39
+ // Polymorphes Klick-Ziel (öffnet die EditorPanel-Maske via Target-
40
+ // Resolver) — Alternative zu `screen`. Ein Knoten trägt screen XOR
41
+ // target; der Renderer dispatcht das target statt einen Route-Link zu
42
+ // rendern. Gespiegelt aus dem alten Visual-Tree (TreeNode.target).
43
+ readonly target?: TargetRef;
44
+ // Hover-Actions rechts in der Zeile (VS-Code-Pattern) — erst bei Hover
45
+ // sichtbar. Reihenfolge wie deklariert.
46
+ readonly actions?: readonly TreeAction[];
47
+ // „+"-Affordance am Knoten. Klick dispatcht createAction.target; der
48
+ // Provider weiß was „leer befüllen" für ihn heißt (neuer Page-Slug etc.).
49
+ readonly createAction?: TreeAction;
50
+ // Children kommen zur Laufzeit aus einem registrierten nav-provider
51
+ // (lazy beim Ausklappen, SSE-live via treeEntities), keyed auf diese
52
+ // Nav-QN. Macht den Knoten expandable auch ohne statische children.
53
+ readonly provider?: boolean;
54
+ // Role / openToAll gate. The nav resolver hides entries the user can't
55
+ // reach; leave unset to always show (engine stays un-opinionated about
56
+ // who sees what — apps that need default-deny can set { roles: [] }).
57
+ // If `screen` is set and `access` is left unset, the client-side nav
58
+ // builder (buildNavRegistrySliceForApp) fills this in from the target
59
+ // screen's own `access` — a nav entry never invites a role into a 403.
60
+ // An explicit `access` here always wins over the screen's.
61
+ readonly access?: AccessRule;
62
+ // Workspace QNs this entry self-assigns to. Merged at boot with any
63
+ // r.workspace({ nav: [...] }) explicit lists. Omit to leave workspace
64
+ // membership decided solely by the workspace's nav list (or both empty
65
+ // → entry belongs to no workspace).
66
+ readonly workspaces?: readonly string[];
67
+ };
@@ -0,0 +1,83 @@
1
+ // --- Ownership ---
2
+ // Pure types for the declarative Claims → Access bridge. Runtime (from(),
3
+ // matchesRule(), buildOwnershipClause()) stays in engine/ownership.ts, which
4
+ // re-exports these for backwards compatibility.
5
+
6
+ import type { SessionUser } from "./handlers";
7
+
8
+ // Parameterised SQL fragment — produced by buildOwnershipClause + by the
9
+ // WhereRule escape-hatch. Caller weaves `sqlText` into a larger statement,
10
+ // renumbering placeholders if needed (shiftParams in engine/ownership.ts).
11
+ export type SqlFragment = {
12
+ readonly sqlText: string;
13
+ readonly params: readonly unknown[];
14
+ };
15
+
16
+ // Reference spec supported by `from()`:
17
+ // "user:id" → user.id
18
+ // "user:tenantId" → user.tenantId (rarely needed — TenantDb scopes anyway)
19
+ // "claim:<featureName>:<key>" → user.claims["<featureName>:<key>"]
20
+ //
21
+ // The string form is keyed so the framework can look up the referenced
22
+ // Registry entry at boot (Claim-QN exists? Column type compatible?). A typed
23
+ // object form would force features to import each other's handles — the
24
+ // whole point of H.2's unified path is string-based references, no imports.
25
+ export type OwnershipRef = string;
26
+
27
+ // Resolved during `from()` — the parser eagerly splits the prefix so the
28
+ // runtime evaluator avoids string-parsing on every row. `kind` drives the
29
+ // evaluator branch; the rest is the resolved metadata.
30
+ export type FromRuleKind = "user" | "claim";
31
+
32
+ export type FromRule = {
33
+ readonly kind: "from";
34
+ readonly refKind: FromRuleKind;
35
+ // For "user:id" → "id"; for "user:tenantId" → "tenantId".
36
+ // For "claim:<featureName>:<key>" → "<featureName>:<key>" (the full QN,
37
+ // which is exactly the key under which the JWT stores the value).
38
+ readonly refPath: string;
39
+ // Row-column to match against. For claim rules defaults to the claim's
40
+ // shortName (second segment of the claim QN). For user-rules the column
41
+ // is always explicit.
42
+ readonly column: string;
43
+ };
44
+
45
+ // Context passed to a WhereRule escape-hatch. The author returns a SqlFragment
46
+ // whose placeholders start at `paramStart` ($N, $N+1, ...); the framework
47
+ // concatenates the fragment into the larger query.
48
+ export type WhereRuleContext<TTable = unknown> = {
49
+ readonly table: TTable;
50
+ readonly tableName: string;
51
+ readonly paramStart: number;
52
+ };
53
+
54
+ export type WhereRule<TTable = unknown> = {
55
+ readonly kind: "where";
56
+ readonly where: (user: SessionUser, ctx: WhereRuleContext<TTable>) => SqlFragment;
57
+ };
58
+
59
+ // "all" collapses to a primitive so map authors can write `Admin: "all"`
60
+ // without importing a helper.
61
+ export type OwnershipRule = "all" | FromRule | WhereRule;
62
+
63
+ // Per-role map: every key is a role name, value is the rule that role
64
+ // satisfies to pass the access check.
65
+ export type OwnershipMap = Readonly<Record<string, OwnershipRule>>;
66
+
67
+ // Result of buildOwnershipClause. The discriminant lets the caller handle
68
+ // the three outcomes without inspecting SQL internals:
69
+ //
70
+ // "pass" → user is unrestricted. Run the query as-is.
71
+ // "empty" → user has a role mapped but no rule accepts any row (missing
72
+ // claim, empty array, role not in map). Skip the DB call entirely
73
+ // — returning [] is equivalent and avoids a pointless roundtrip.
74
+ // "sql" → apply the parameterised fragment as an AND on the query.
75
+ // Caller is responsible for renumbering placeholders when
76
+ // concatenating with other fragments (see `shiftParams`).
77
+ //
78
+ // "empty" vs. "pass" is the critical distinction for a safe default:
79
+ // undefined/pass = allow, empty = deny-by-construction.
80
+ export type OwnershipClause =
81
+ | { readonly kind: "pass" }
82
+ | { readonly kind: "empty" }
83
+ | { readonly kind: "sql"; readonly sqlText: string; readonly params: readonly unknown[] };
@@ -0,0 +1,164 @@
1
+ import type { RunIn } from "./config";
2
+ import type { DbRunner } from "./db-connection";
3
+ import type { StoredEvent } from "./event-store-types";
4
+ import type { EntityDefinition } from "./fields";
5
+ import type { MultiStreamApplyContext } from "./multi-stream-apply-context-types";
6
+ import type { SchemaTable } from "./schema-table-types";
7
+
8
+ // Drizzle pgTable shape — projections hand their table through to apply() so
9
+ // user code writes upserts/updates directly instead of going through a
10
+ // framework-managed state reducer. Using the native dialect's `SchemaTable`
11
+ // (drizzle-compat surface) keeps typing honest: typed paths work inside
12
+ // apply(), but the column union is erased so framework code doesn't need
13
+ // to know the schema shape of every user table.
14
+ export type ProjectionTable = SchemaTable;
15
+
16
+ // Single-stream projection apply: runs inline in the write-TX of the event
17
+ // it projects. Gets the event, the TX-scoped DbRunner, and the projection's
18
+ // own `table` (already erased to ProjectionTable). Write through THAT table —
19
+ // it is the only ES-blessed write into a managed projection outside the
20
+ // executor, and it is reachable only here (an arbitrary handler has no such
21
+ // arg), so the write-brand cannot be bypassed by closing over the branded
22
+ // table constant. Inline projections must not spawn further events (no ctx)
23
+ // because they run inside the command's transaction and the framework
24
+ // guarantees a single commit boundary per command.
25
+ //
26
+ // Generic über payload-shape. Default = Record<string, unknown> behält
27
+ // rückwärtskompatibles Verhalten; Konkrete Apply-Handler annotieren
28
+ // `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access. Der
29
+ // `table`-Param ist additiv — 2-arg-Applies (event, tx) bleiben gültig.
30
+ export type SingleStreamApplyFn<TPayload = Record<string, unknown>> = (
31
+ event: StoredEvent<TPayload>,
32
+ tx: DbRunner,
33
+ table: ProjectionTable,
34
+ ) => Promise<void>;
35
+
36
+ // Multi-stream projection apply: runs asynchronously via the event-dispatcher
37
+ // with its own cursor. Gets the event, tx, and a ctx surface for emitting
38
+ // follow-up events (saga / process-manager pattern). ctx.appendEvent +
39
+ // ctx.loadAggregate are the Marten-equivalent of IProjectionSession — write
40
+ // cross-aggregate reactions here, not in single-stream projections.
41
+ export type MultiStreamApplyFn<TPayload = Record<string, unknown>> = (
42
+ event: StoredEvent<TPayload>,
43
+ tx: DbRunner,
44
+ ctx: MultiStreamApplyContext,
45
+ ) => Promise<void>;
46
+
47
+ export type ProjectionDefinition = {
48
+ readonly name: string;
49
+ // One or more entity names whose events feed this projection. Event-types
50
+ // are matched in `apply` (e.g. "unit.created") — `source` is only used to
51
+ // index projections so the executor doesn't scan all projections on every
52
+ // write.
53
+ readonly source: string | readonly string[];
54
+ // Additional aggregate-types whose events the rebuild replay must include
55
+ // beyond `source`. Fed by r.extendEntityProjection — an extension can react
56
+ // to events on foreign streams (e.g. "field-definition") while `source`
57
+ // keeps meaning "the owning entity" for consumers like soft-delete-cleanup.
58
+ readonly extraSources?: readonly string[];
59
+ // Drizzle-table the projection materializes into. User owns the schema —
60
+ // framework just guarantees the TX and event delivery.
61
+ readonly table: ProjectionTable;
62
+ // Optional: the EntityDefinition the table was built from, for projections
63
+ // without an r.entity registration — lets boot-time GDPR guards see
64
+ // pii/tenantOwned fields that feature.entities (r.entity-only) would miss.
65
+ readonly entity?: EntityDefinition;
66
+ // Keyed by fully-qualified event type ("<aggregate>.<verb>", e.g. "unit.created").
67
+ // Missing keys are silently skipped — a projection declares only the events it
68
+ // cares about.
69
+ readonly apply: Readonly<Record<string, SingleStreamApplyFn>>;
70
+ // Auto-registered projection (one per r.entity) that exists ONLY to
71
+ // make rebuildProjection work for entity-tables. Live writes go through
72
+ // the EventStoreExecutor directly — firing the implicit apply inline
73
+ // would double-write into the same table. The inline-projection-runner
74
+ // skips entries with this flag; rebuildProjection treats them
75
+ // identically to explicit projections.
76
+ readonly isImplicit?: boolean;
77
+ };
78
+
79
+ // Extension merged into an entity's implicit projection at registry build.
80
+ // Lets a bundled feature that writes into the HOST entity's table via events
81
+ // (custom-fields pattern) hook those event types into the entity's rebuild
82
+ // replay — without it, a rebuild resets everything the extension wrote.
83
+ // Live delivery stays with the extension's own MSP: implicit projections are
84
+ // skipped by the inline runner, so the apply here runs ONLY during rebuild.
85
+ export type EntityProjectionExtension = {
86
+ // Aggregate-types beyond the entity's own stream whose events the rebuild
87
+ // must scan (e.g. "field-definition"). Omit when all extension events are
88
+ // appended on the host entity's stream.
89
+ readonly sources?: readonly string[];
90
+ // Keyed by fully-qualified event type. Must not collide with the entity's
91
+ // built-in lifecycle applies (<entity>.created/updated/...) or another
92
+ // extension — collisions fail at boot.
93
+ readonly apply: Readonly<Record<string, SingleStreamApplyFn>>;
94
+ };
95
+
96
+ // Per-lifecycle error policy for a MultiStreamProjection. Mirrors Marten's
97
+ // Projections.Errors / Projections.RebuildErrors split — a projection can
98
+ // be lenient during steady-state delivery but strict during rebuild (or
99
+ // vice versa).
100
+ export type MspErrorPolicy = {
101
+ // When the apply handler throws: log the error, advance the cursor past
102
+ // the offending event, and keep delivering. Default false — current
103
+ // strict behaviour: retry up to maxAttempts, then mark the consumer
104
+ // status="dead" and pause delivery. Use for best-effort sinks
105
+ // (notifications, webhooks) where a single bad event should not stall
106
+ // the whole consumer.
107
+ readonly skipApplyErrors?: boolean;
108
+ };
109
+
110
+ export type MspErrorMode = {
111
+ // Applied during steady-state dispatcher delivery.
112
+ readonly continuous?: MspErrorPolicy;
113
+ // Applied during rebuildProjection() / backfill passes. When omitted,
114
+ // rebuild inherits continuous — explicit override common for "strict
115
+ // during rebuild, lenient in production" patterns.
116
+ readonly rebuild?: MspErrorPolicy;
117
+ };
118
+
119
+ // Marten-style MultiStreamProjection: aggregates events from many streams
120
+ // into one cross-cutting read-model. Unlike ProjectionDefinition (single-
121
+ // source, inline in the write-TX), an MSP is ASYNC — the event-dispatcher
122
+ // picks events off the log via its own cursor. Handlers MUST be idempotent
123
+ // because the dispatcher guarantees at-least-once delivery.
124
+ //
125
+ // Use for Sagas / process managers, customer-centric views that span
126
+ // multiple aggregate types, cross-feature aggregations, audit logs. With
127
+ // `table` omitted, the MSP becomes a pure side-effect consumer — sending
128
+ // notifications, posting webhooks, updating an external system. Marten's
129
+ // equivalent of a subscription / event listener, without a separate API.
130
+ export type MultiStreamProjectionDefinition = {
131
+ readonly name: string;
132
+ // Optional: omit for side-effect-only handlers (notifications, external
133
+ // system sync). When present, setupTestStack auto-pushes the table.
134
+ readonly table?: ProjectionTable;
135
+ // Keyed by fully-qualified event type. Unlike a single-stream projection,
136
+ // there is no source-entity hint — the MSP declares the event types it
137
+ // cares about directly. Extract the identity/grouping key inside the
138
+ // apply handler from the event payload.
139
+ readonly apply: Readonly<Record<string, MultiStreamApplyFn>>;
140
+ // How the dispatcher handles apply-throws. Default strict (retry + dead).
141
+ readonly errorMode?: MspErrorMode;
142
+ // Which deploy-lane runs this MSP's dispatcher. Default "worker". MSPs
143
+ // share a single consumer-row per MSP name with SKIP LOCKED, so "both"
144
+ // is safe semantically (API + Worker race for each event; exactly one
145
+ // wins). Use "api" for MSPs that need in-process state on the API
146
+ // (rare); use "both" only when genuinely load-balancing is helpful.
147
+ readonly runIn?: RunIn;
148
+ // Delivery semantics across multi-instance deploys:
149
+ // "shared" (default) — one cursor across all dispatcher instances,
150
+ // SKIP LOCKED serialises; each event delivered exactly
151
+ // once globally. The right choice for side-effects with
152
+ // any downstream state: notifications, external APIs,
153
+ // projection tables, audit rows.
154
+ // "per-instance" — one cursor PER dispatcher instance, so every process
155
+ // delivers every event. Required for push-to-local-
156
+ // subscribers (SSE, in-memory caches): a split-deploy
157
+ // where API instance B emits an event that API instance
158
+ // A's clients also need to see. Handler MUST be
159
+ // side-effect-free relative to the DB — it only reaches
160
+ // in-process structures — otherwise each instance
161
+ // writes duplicate rows. Misuse = duplicated side
162
+ // effects, not a safety property.
163
+ readonly delivery?: "shared" | "per-instance";
164
+ };