@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
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
|
+
};
|
package/src/ownership.ts
ADDED
|
@@ -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
|
+
};
|