@cosmicdrift/kumiko-framework 0.146.2 → 0.146.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.146.2",
3
+ "version": "0.146.4",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.146.2",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.146.4",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -0,0 +1,187 @@
1
+ import type { DbConnection } from "../db/connection";
2
+ import { transaction } from "../db/query";
3
+ import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
4
+ import { InternalError, toWriteErrorInfo, writeFailure } from "../errors";
5
+ import { createFallbackLogger } from "../logging/utils";
6
+ import { parseJsonSafe } from "../utils/safe-json";
7
+ import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
8
+ import { resolveDbSource } from "./dispatch-shared";
9
+ import { executeNestedWrite } from "./dispatch-write";
10
+ import {
11
+ type AfterCommitHook,
12
+ BatchRollback,
13
+ isLifecycleResult,
14
+ wrapToKumiko,
15
+ } from "./dispatcher-utils";
16
+
17
+ // Core batch logic extracted so write() and command() can reuse it
18
+ // (a single write = batch of one, running in its own transaction).
19
+ export async function runBatch(
20
+ ctx: DispatchContext,
21
+ commands: readonly BatchCommand[],
22
+ user: SessionUser,
23
+ requestId?: string,
24
+ ): Promise<BatchResult> {
25
+ const { idempotency, lifecycle, appContext: context } = ctx;
26
+ if (commands.length === 0) {
27
+ return { isSuccess: true, results: [] };
28
+ }
29
+
30
+ // Idempotency: if the same requestId has already been processed, return the
31
+ // cached result without re-executing. The cache holds the full BatchResult.
32
+ if (requestId && idempotency) {
33
+ const cached = await idempotency.check(requestId);
34
+ if (cached) {
35
+ const parsed = parseJsonSafe<BatchResult | null>(cached, null);
36
+ if (parsed) return parsed;
37
+ // corrupted cache entry — treat as miss, let the request re-run
38
+ }
39
+ }
40
+
41
+ // Wrap return paths: cache the final result under requestId so retries get
42
+ // the same answer (both success and failure results are cached).
43
+ const finalize = async (result: BatchResult): Promise<BatchResult> => {
44
+ if (requestId && idempotency) {
45
+ await idempotency.store(requestId, result);
46
+ }
47
+ return result;
48
+ };
49
+
50
+ const afterCommitHooks: AfterCommitHook[] = [];
51
+ const results: WriteResult[] = [];
52
+
53
+ // Flush afterCommit hooks in parallel. Errors are logged, not rethrown:
54
+ // the writes are already committed, we can't undo them.
55
+ //
56
+ // Parallelisation is safe because afterCommit hooks are deferred side-
57
+ // effects (e.g. feature-level postSave hooks in afterCommit phase)
58
+ // that don't depend on each other — the in-transaction work already ran
59
+ // sequentially inside the lifecycle pipeline where ordering matters. If a
60
+ // future hook ever needs ordering, it should do its sequencing internally
61
+ // (one hook pushing multiple sub-calls) rather than relying on the
62
+ // flush-loop order.
63
+ const flushAfterCommit = async () => {
64
+ const logError = createFallbackLogger("dispatcher", context.log);
65
+ const outcomes = await Promise.allSettled(afterCommitHooks.map((hook) => hook()));
66
+ for (const outcome of outcomes) {
67
+ if (outcome.status === "rejected") {
68
+ const detail =
69
+ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
70
+ logError.error("afterCommit hook failed", { error: detail });
71
+ }
72
+ }
73
+ };
74
+
75
+ // Fires the batch-level system hooks with every successful save/delete
76
+ // context from this run. Called after flushAfterCommit so per-save hooks
77
+ // have all completed first; errors are isolated inside lifecycleHooks.
78
+ const flushBatchHooks = async () => {
79
+ try {
80
+ const saves: SaveContext[] = [];
81
+ const deletes: DeleteContext[] = [];
82
+ for (const r of results) {
83
+ if (!r.isSuccess) continue;
84
+ if (!isLifecycleResult(r.data)) continue;
85
+ if (r.data.kind === "save") saves.push(r.data);
86
+ else if (r.data.kind === "delete") deletes.push(r.data);
87
+ }
88
+ if (saves.length > 0 && lifecycle) await lifecycle.runPostSaveBatch(saves, context);
89
+ if (deletes.length > 0 && lifecycle) await lifecycle.runPostDeleteBatch(deletes, context);
90
+ } catch (e) {
91
+ // Batch hooks must never fail the batch — the commit already happened.
92
+ // Pass the raw error so the logger preserves stack + cause chain;
93
+ // collapsing to .message hides exactly what ops needs to debug.
94
+ const logError = createFallbackLogger("dispatcher", context.log);
95
+ logError.error("batch hook flush failed", { error: e });
96
+ }
97
+ };
98
+
99
+ // batch() opens its own outer transaction — needs the top-level
100
+ // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
101
+ const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
102
+ if (!db) {
103
+ // Without a DB connection there is no transaction to open. Fall back to
104
+ // sequential execution — useful for unit tests that don't touch the DB.
105
+ // Each command runs independently; a failure stops the batch.
106
+ for (let i = 0; i < commands.length; i++) {
107
+ const cmd = commands[i];
108
+ if (!cmd) continue;
109
+ const res = await executeNestedWrite(
110
+ ctx,
111
+ cmd.type,
112
+ cmd.payload,
113
+ user,
114
+ undefined,
115
+ afterCommitHooks,
116
+ );
117
+ results.push(res);
118
+ if (!res.isSuccess) {
119
+ // No tx means no rollback — but we still drop afterCommit hooks,
120
+ // matching the semantic "failure = side-effects don't fire".
121
+ return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
122
+ }
123
+ }
124
+ await flushAfterCommit();
125
+ await flushBatchHooks();
126
+ return finalize({ isSuccess: true, results });
127
+ }
128
+
129
+ try {
130
+ await transaction(db, async (tx) => {
131
+ for (let i = 0; i < commands.length; i++) {
132
+ const cmd = commands[i];
133
+ if (!cmd) continue;
134
+ const res = await executeNestedWrite(
135
+ ctx,
136
+ cmd.type,
137
+ cmd.payload,
138
+ user,
139
+ tx,
140
+ afterCommitHooks,
141
+ );
142
+ results.push(res);
143
+ if (!res.isSuccess) {
144
+ throw new BatchRollback(i, res.error);
145
+ }
146
+ }
147
+ });
148
+ } catch (e) {
149
+ if (e instanceof BatchRollback) {
150
+ return finalize({
151
+ isSuccess: false,
152
+ error: e.failureError,
153
+ failedIndex: e.failedIndex,
154
+ results,
155
+ });
156
+ }
157
+ return finalize({
158
+ isSuccess: false,
159
+ error: toWriteErrorInfo(wrapToKumiko(e)),
160
+ failedIndex: results.length,
161
+ results,
162
+ });
163
+ }
164
+
165
+ // Commit succeeded — fire deferred side-effects.
166
+ await flushAfterCommit();
167
+ await flushBatchHooks();
168
+ return finalize({ isSuccess: true, results });
169
+ }
170
+
171
+ // Unwrap a BatchResult into a single WriteResult for write()/command().
172
+ // Picks the last result if present (the failing one for failures, the only
173
+ // one for successful single writes). Falls back to a synthetic error if the
174
+ // batch didn't produce any results (unexpected).
175
+ export function unwrapSingle(batchResult: BatchResult): WriteResult {
176
+ if (batchResult.isSuccess) {
177
+ return (
178
+ batchResult.results[0] ?? writeFailure(new InternalError({ message: "empty_batch_result" }))
179
+ );
180
+ }
181
+ return (
182
+ batchResult.results[batchResult.failedIndex] ?? {
183
+ isSuccess: false,
184
+ error: batchResult.error,
185
+ }
186
+ );
187
+ }
@@ -0,0 +1,165 @@
1
+ import type { DbRow, DbTx } from "../db/connection";
2
+ import { hasAccess } from "../engine/access";
3
+ import { filterReadFields } from "../engine/field-access";
4
+ import type { SessionUser } from "../engine/types";
5
+ import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
6
+ import { assertNoSecretLeak } from "../secrets";
7
+ import type { DispatchContext } from "./dispatch-shared";
8
+ import {
9
+ buildHandlerContext,
10
+ enforceRateLimit,
11
+ ensureFeatureEnabled,
12
+ runHandlerInstrumented,
13
+ } from "./dispatch-shared";
14
+
15
+ // Standalone query execution — used by the public dispatcher.query() and
16
+ // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
17
+ // field-level read filters for the given user, logs the event.
18
+ export async function executeQuery(
19
+ ctx: DispatchContext,
20
+ type: string,
21
+ payload: unknown,
22
+ user: SessionUser,
23
+ tx?: DbTx,
24
+ ): Promise<unknown> {
25
+ return runHandlerInstrumented(ctx, type, "query", user, () =>
26
+ executeQueryInner(ctx, type, payload, user, tx),
27
+ );
28
+ }
29
+
30
+ async function executeQueryInner(
31
+ ctx: DispatchContext,
32
+ type: string,
33
+ payload: unknown,
34
+ user: SessionUser,
35
+ tx?: DbTx,
36
+ ): Promise<unknown> {
37
+ const { registry } = ctx;
38
+ const handler = registry.getQueryHandler(type);
39
+ if (!handler) throw new NotFoundError("handler", type);
40
+
41
+ // Feature-toggle gate runs BEFORE rate-limit on purpose: calls to a
42
+ // disabled feature must not consume the rate-limit quota — the call
43
+ // never happened from the feature's perspective. Order is: lookup →
44
+ // feature-gate → rate-limit → access → validation → handler.
45
+ await ensureFeatureEnabled(ctx, type, user.tenantId);
46
+
47
+ // Rate-limit gate runs BEFORE access-check on purpose: anonymous /
48
+ // unauthorized callers must hit the cap too (otherwise the limit
49
+ // would be a free probe-detector for valid credentials). The
50
+ // resolver throws RateLimitError which the dispatcher's outer
51
+ // wrapper turns into a 429 response. Inline-skip when the handler
52
+ // didn't opt in — keeps the hot path zero-cost (no await on a
53
+ // no-op promise).
54
+ if (handler.rateLimit !== undefined) {
55
+ await enforceRateLimit(ctx, handler.rateLimit, type, user);
56
+ }
57
+
58
+ // Default-deny: missing access rule is treated as "no one has access".
59
+ // The registry boot-validator refuses to register handlers without one,
60
+ // so in normal boots this branch shouldn't fire — the guard is belt-and-
61
+ // suspenders in case a handler sneaks through (e.g. runtime injection).
62
+ if (!hasAccess(user, handler.access)) {
63
+ throw new AccessDeniedError({
64
+ message: `access denied for ${type}`,
65
+ details: { handler: type },
66
+ });
67
+ }
68
+
69
+ const parsed = handler.schema.safeParse(payload);
70
+ if (!parsed.success) {
71
+ throw validationErrorFromZod(parsed.error);
72
+ }
73
+
74
+ // Trash opt-in rides the validated query payload: only the entity-list
75
+ // schema (and custom query schemas that opt in) carries `includeDeleted`,
76
+ // so other handlers never see the flag. Visibility filters still apply
77
+ // downstream (see HandlerContext.includeDeleted) — safe from raw input.
78
+ const includeDeleted =
79
+ typeof parsed.data === "object" &&
80
+ parsed.data !== null &&
81
+ (parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
82
+ const handlerContext = buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
83
+ let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
84
+
85
+ // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
86
+ // and can merge custom-fields/computed-counts/tags/etc. Each hook is
87
+ // responsible for its own field-access on values it adds (the filter
88
+ // below only knows the entity's stammfields).
89
+ //
90
+ // Two firing-pfade kombiniert in dieser Reihenfolge:
91
+ // 1. Handler-keyed hooks via r.hook("postQuery", "ns:query:list", fn)
92
+ // — feuern nur für genau diesen handler
93
+ // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
94
+ // — feuern für ALLE query-handlers des entity
95
+ const entityName = registry.getHandlerEntity(type);
96
+
97
+ // Handler-keyed postQuery hooks fire for any query (incl. entity-less
98
+ // standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
99
+ // when the handler maps to an entity — so this block must NOT be gated on
100
+ // entityName, or hooks on standalone queries register silently and never fire.
101
+ const handlerHooks = registry.getPostQueryHooks(type);
102
+ const entityHooks = entityName ? registry.getEntityPostQueryHooks(entityName) : [];
103
+ const postQueryHooks = [...handlerHooks, ...entityHooks];
104
+ if (postQueryHooks.length > 0 && result && typeof result === "object") {
105
+ if (Array.isArray(result)) {
106
+ let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
107
+ for (const hook of postQueryHooks) {
108
+ const out = await hook({ entityName, rows }, handlerContext);
109
+ rows = [...out.rows];
110
+ }
111
+ result = rows;
112
+ } else if (Array.isArray((result as { rows?: unknown }).rows)) {
113
+ // @cast-boundary engine-payload
114
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
115
+ let rows = r.rows;
116
+ for (const hook of postQueryHooks) {
117
+ const out = await hook({ entityName, rows }, handlerContext);
118
+ rows = [...out.rows];
119
+ }
120
+ result = { ...r, rows };
121
+ } else {
122
+ let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
123
+ for (const hook of postQueryHooks) {
124
+ const out = await hook({ entityName, rows }, handlerContext);
125
+ rows = [...out.rows];
126
+ }
127
+ // A single-object result carries exactly one row through the hook
128
+ // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
129
+ // dropped) cannot be represented in the single-object response —
130
+ // surface it instead of silently falling back / truncating.
131
+ if (rows.length !== 1) {
132
+ throw new Error(
133
+ `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
134
+ );
135
+ }
136
+ result = rows[0];
137
+ }
138
+ }
139
+
140
+ // Field-level read filter — only applies to entity-bound results.
141
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
142
+ if (entity && result && typeof result === "object") {
143
+ if (Array.isArray(result)) {
144
+ result = result.map((row: Record<string, unknown>) => filterReadFields(entity, row, user));
145
+ } else {
146
+ const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
147
+ if (Array.isArray((resultAsDbRow as { rows?: unknown }).rows)) {
148
+ // generic handler-result shape narrow
149
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
150
+ result = {
151
+ ...r,
152
+ rows: r.rows.map((row) => filterReadFields(entity, row, user)),
153
+ };
154
+ } else {
155
+ result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
156
+ }
157
+ }
158
+ }
159
+
160
+ // Response-guard: fail the request if a handler accidentally included
161
+ // a Secret<> branded value in its return. Must run AFTER field-access
162
+ // filtering so a legitimately stripped secret doesn't false-positive.
163
+ assertNoSecretLeak(result);
164
+ return result;
165
+ }