@cosmicdrift/kumiko-framework 0.146.1 → 0.146.3
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 +2 -2
- package/src/api/auth-routes.ts +4 -5
- package/src/engine/__tests__/codemod-pipeline.test.ts +13 -11
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/define-handler.ts +2 -2
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/index.ts +7 -5
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/step.ts +4 -4
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +685 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher.ts +30 -1374
|
@@ -370,7 +370,7 @@ export function generatePerformBlock(
|
|
|
370
370
|
const pipelineType = schemaType ? `<${schemaType}, unknown>` : "";
|
|
371
371
|
|
|
372
372
|
return [
|
|
373
|
-
`perform:
|
|
373
|
+
`perform: stepsPipeline${pipelineType}(({ event, r }) => [`,
|
|
374
374
|
stepsStr,
|
|
375
375
|
` ${stepIndent}]),`,
|
|
376
376
|
].join("\n");
|
|
@@ -513,7 +513,7 @@ function contentHasPipelineImport(content: string): boolean {
|
|
|
513
513
|
const importLine = content
|
|
514
514
|
.split("\n")
|
|
515
515
|
.find((l) => l.includes("import") && l.includes("@cosmicdrift/kumiko-framework/engine"));
|
|
516
|
-
return !!importLine && importLine.includes("
|
|
516
|
+
return !!importLine && importLine.includes("stepsPipeline");
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
function ensurePipelineImport(content: string): string | null {
|
|
@@ -525,7 +525,7 @@ function ensurePipelineImport(content: string): string | null {
|
|
|
525
525
|
const match = content.match(importRegex);
|
|
526
526
|
if (match) {
|
|
527
527
|
const existingImports = (match[1] as string).trim();
|
|
528
|
-
const newImports = existingImports ? `${existingImports},
|
|
528
|
+
const newImports = existingImports ? `${existingImports}, stepsPipeline` : "stepsPipeline";
|
|
529
529
|
return content.replace(
|
|
530
530
|
importRegex,
|
|
531
531
|
`import { ${newImports} } from "@cosmicdrift/kumiko-framework/engine"`,
|
|
@@ -611,7 +611,7 @@ export async function convertFile(
|
|
|
611
611
|
if (importResult) {
|
|
612
612
|
content = importResult;
|
|
613
613
|
} else if (options.verbose) {
|
|
614
|
-
console.log(` ~ ${filePath}:
|
|
614
|
+
console.log(` ~ ${filePath}: stepsPipeline import not needed or already present`);
|
|
615
615
|
}
|
|
616
616
|
}
|
|
617
617
|
|
|
@@ -621,7 +621,7 @@ export async function convertFile(
|
|
|
621
621
|
|
|
622
622
|
const status = hadChanges ? "converted" : "skipped";
|
|
623
623
|
const reason = hadChanges
|
|
624
|
-
? "handler replaced with perform:
|
|
624
|
+
? "handler replaced with perform: stepsPipeline(...)"
|
|
625
625
|
: "no convertible handler";
|
|
626
626
|
return { filePath, status, reason };
|
|
627
627
|
} catch (err) {
|
|
@@ -23,7 +23,7 @@ import type { PipelineDef } from "./types/step";
|
|
|
23
23
|
// visible) and collapse `keyof TMap` to `never`. See the spike-findings
|
|
24
24
|
// memory for the empirical proof.
|
|
25
25
|
//
|
|
26
|
-
// Two authoring forms — `handler` (free-form) or `perform:
|
|
26
|
+
// Two authoring forms — `handler` (free-form) or `perform: stepsPipeline(...)`
|
|
27
27
|
// (step-pipeline). A `perform` is compiled to a handler-function at
|
|
28
28
|
// definition time; the dispatcher only ever sees `handler`.
|
|
29
29
|
|
|
@@ -126,7 +126,7 @@ export function defineWriteHandler<
|
|
|
126
126
|
throw new Error(
|
|
127
127
|
`defineWriteHandler("${def.name}"): both \`handler\` and \`perform\` are set. ` +
|
|
128
128
|
`Pick one — \`handler\` for the free-form async function, ` +
|
|
129
|
-
`\`perform:
|
|
129
|
+
`\`perform: stepsPipeline(...)\` for the step-pipeline form. ` +
|
|
130
130
|
`(See step-vocabulary.md for which form fits.)`,
|
|
131
131
|
);
|
|
132
132
|
}
|
|
@@ -57,7 +57,7 @@ export type WorkflowInput<TPayload = unknown, TData = unknown> = {
|
|
|
57
57
|
* defineWorkflow({
|
|
58
58
|
* name: "user-onboarding",
|
|
59
59
|
* trigger: { kind: "event", eventType: "user.signed-up" },
|
|
60
|
-
* steps:
|
|
60
|
+
* steps: stepsPipeline(({ event, r }) => [
|
|
61
61
|
* r.step.mail.send({ to: () => event.payload.email, subject: "Welcome!", body: "..." }),
|
|
62
62
|
* r.step.wait({ for: "P1D" }),
|
|
63
63
|
* r.step.read.findOne("user", { table: userTable, where: ... }),
|
package/src/engine/index.ts
CHANGED
|
@@ -179,18 +179,20 @@ export {
|
|
|
179
179
|
filterReadFields,
|
|
180
180
|
} from "./field-access";
|
|
181
181
|
// findForbiddenMembershipRole/isForbiddenMembershipRole/
|
|
182
|
-
// stripForbiddenMembershipRoles are Public API for host
|
|
183
|
-
// their own membership handlers. FORBIDDEN_MEMBERSHIP_ROLES
|
|
184
|
-
// internal (637/3) — exporting the raw Set would make its
|
|
185
|
-
// semver promise; the predicate functions are the intended
|
|
182
|
+
// stripForbiddenMembershipRoles/buildSessionRoles are Public API for host
|
|
183
|
+
// apps that build their own membership handlers. FORBIDDEN_MEMBERSHIP_ROLES
|
|
184
|
+
// itself stays internal (637/3) — exporting the raw Set would make its
|
|
185
|
+
// representation a semver promise; the predicate functions are the intended
|
|
186
|
+
// surface.
|
|
186
187
|
export {
|
|
188
|
+
buildSessionRoles,
|
|
187
189
|
findForbiddenMembershipRole,
|
|
188
190
|
isForbiddenMembershipRole,
|
|
189
191
|
stripForbiddenMembershipRoles,
|
|
190
192
|
} from "./membership-roles";
|
|
191
193
|
export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from "./ownership";
|
|
192
194
|
export { from } from "./ownership";
|
|
193
|
-
export { buildPipelineSteps,
|
|
195
|
+
export { buildPipelineSteps, stepsPipeline } from "./pipeline";
|
|
194
196
|
export { defineApply, defineMspApply, setFields } from "./projection-helpers";
|
|
195
197
|
export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
|
|
196
198
|
export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name";
|
|
@@ -30,3 +30,16 @@ export function findForbiddenMembershipRole(roles: readonly string[]): string |
|
|
|
30
30
|
export function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[] {
|
|
31
31
|
return roles.filter((role) => !isForbiddenMembershipRole(role));
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// Single mint path for session roles: merges globalRoles with the stripped
|
|
35
|
+
// membership portion and dedupes. Every place that builds a session's roles
|
|
36
|
+
// from a membership should go through this instead of repeating
|
|
37
|
+
// `new Set([...globalRoles, ...stripForbiddenMembershipRoles(x)])` — a new
|
|
38
|
+
// mint site that forgets the strip is exactly the bug class this guards
|
|
39
|
+
// against.
|
|
40
|
+
export function buildSessionRoles(
|
|
41
|
+
globalRoles: readonly string[],
|
|
42
|
+
membershipRoles: readonly string[],
|
|
43
|
+
): readonly string[] {
|
|
44
|
+
return Array.from(new Set([...globalRoles, ...stripForbiddenMembershipRoles(membershipRoles)]));
|
|
45
|
+
}
|
package/src/engine/pipeline.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// stepsPipeline() — public factory used in defineWriteHandler({ perform: stepsPipeline(...) }).
|
|
2
2
|
//
|
|
3
3
|
// The closure receives { event, r } and returns the immutable list of
|
|
4
4
|
// step instances. `r` is the StepBuilder singleton; new tier-1 steps
|
|
@@ -8,15 +8,10 @@
|
|
|
8
8
|
// the resolver-side PipelineCtx (run-pipeline.ts). Resolvers that need
|
|
9
9
|
// prior step results destructure them from the resolver's ctx.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// grep for `pipeline` returns mixed results. If a rename ever lands,
|
|
16
|
-
// candidates are `r.steps([...])` for the public API or
|
|
17
|
-
// `engine-pipeline/` / `runtime/` for the internal directory.
|
|
18
|
-
// Decision-cost grows with each new caller; the rename window narrows
|
|
19
|
-
// after the first external consumer.
|
|
11
|
+
// Renamed from `pipeline()` (Followup #1) — it shared its name with the
|
|
12
|
+
// internal `packages/framework/src/pipeline/` directory (dispatcher,
|
|
13
|
+
// lifecycle, outbox-poller), which made repo-wide grep for `pipeline`
|
|
14
|
+
// return mixed results.
|
|
20
15
|
|
|
21
16
|
import { buildAggregateAppendEventStep } from "./steps/aggregate-append-event";
|
|
22
17
|
import { buildAggregateCreateStep } from "./steps/aggregate-create";
|
|
@@ -69,7 +64,7 @@ const stepBuilder: StepBuilder = {
|
|
|
69
64
|
},
|
|
70
65
|
};
|
|
71
66
|
|
|
72
|
-
export function
|
|
67
|
+
export function stepsPipeline<TPayload = unknown, TData = unknown>(
|
|
73
68
|
closure: (ctx: PipelineBuildCtx<TPayload>) => readonly StepInstance[],
|
|
74
69
|
): PipelineDef<TPayload, TData> {
|
|
75
70
|
return {
|
|
@@ -64,7 +64,7 @@ export async function runPipeline<TPayload, TData, TMap extends object = KumikoE
|
|
|
64
64
|
if (outcome.kind === "return") {
|
|
65
65
|
// RETURN_RESULT_KEY is only produced by r.step.return, whose run()
|
|
66
66
|
// returns WriteResult<unknown>. The pipeline's generic TData is
|
|
67
|
-
// bound at build time (defineWriteHandler ↔
|
|
67
|
+
// bound at build time (defineWriteHandler ↔ stepsPipeline<P, D>(...));
|
|
68
68
|
// matching the runtime value to that compile-time type is the
|
|
69
69
|
// contract user-side. Cast crosses that boundary.
|
|
70
70
|
return outcome.result as WriteResult<TData>;
|
|
@@ -796,7 +796,7 @@ export type WriteHandlerDef = {
|
|
|
796
796
|
readonly access?: AccessRule;
|
|
797
797
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
798
798
|
readonly rateLimit?: RateLimitOption;
|
|
799
|
-
// Set when the author wrote a `perform:
|
|
799
|
+
// Set when the author wrote a `perform: stepsPipeline(...)` block. Boot-
|
|
800
800
|
// validators (projection-allowlist) and Designer/AI tooling read this
|
|
801
801
|
// to inspect the step list. Absent on free-form handlers.
|
|
802
802
|
// Inline-import is intentional: step.ts imports HandlerContext from
|
package/src/engine/types/step.ts
CHANGED
|
@@ -126,7 +126,7 @@ export type StepInstance = {
|
|
|
126
126
|
};
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* What `
|
|
129
|
+
* What `stepsPipeline(closure)` returns. Carries the closure (instead of an
|
|
130
130
|
* eagerly-built array) so each handler-call sees a fresh event ref and
|
|
131
131
|
* the `r` step-builder is resolved at runtime, not at module-load time.
|
|
132
132
|
*
|
|
@@ -140,7 +140,7 @@ export type StepInstance = {
|
|
|
140
140
|
* `noUnusedVariables` marker, not user-facing). _TData is NOT inferred
|
|
141
141
|
* from the closure body (r.step.return has its own per-call TData), so
|
|
142
142
|
* callers must spell it explicitly:
|
|
143
|
-
* `
|
|
143
|
+
* `stepsPipeline<{ greeting: string }, { echoed: string }>(...)`
|
|
144
144
|
* Better DX is a known follow-up — see step-vocabulary.md M.1-Followups.
|
|
145
145
|
*/
|
|
146
146
|
export type PipelineDef<TPayload = unknown, _TData = unknown> = {
|
|
@@ -149,7 +149,7 @@ export type PipelineDef<TPayload = unknown, _TData = unknown> = {
|
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Argument bundle passed to the closure inside `
|
|
152
|
+
* Argument bundle passed to the closure inside `stepsPipeline(closure)`.
|
|
153
153
|
* Build-time only — no `steps`, no `scope`, no `db`: at build time no
|
|
154
154
|
* step has run yet.
|
|
155
155
|
*
|
|
@@ -314,7 +314,7 @@ export type StepNamespace = {
|
|
|
314
314
|
},
|
|
315
315
|
) => StepInstance;
|
|
316
316
|
// --- Tier-3 / Workflow-only steps ---
|
|
317
|
-
// Only available inside defineWorkflow ({ steps:
|
|
317
|
+
// Only available inside defineWorkflow ({ steps: stepsPipeline(...) }).
|
|
318
318
|
// Runtime guard: throws when used inside sync defineWriteHandler.
|
|
319
319
|
readonly wait: (args: { readonly for: StepResolver<string> }) => StepInstance;
|
|
320
320
|
readonly waitForEvent: (args: {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// don't run at build time, so dummy event-payloads are fine.
|
|
14
14
|
//
|
|
15
15
|
// CLOSURE-BODY CONTRACT: the pipeline-closure body (the function passed
|
|
16
|
-
// to `
|
|
16
|
+
// to `stepsPipeline(...)`) must produce its step-list deterministically based
|
|
17
17
|
// on the step-builder `r` alone. Reading `event.payload` outside of
|
|
18
18
|
// resolvers (i.e. at the top of the closure, not inside a step's `row:`
|
|
19
19
|
// or `data:` callback) is forbidden — at boot the dummy payload is `{}`
|
|
@@ -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
|
+
}
|