@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/request-context.ts +28 -0
- package/src/api/server.ts +30 -4
- package/src/changes.json +75 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/boot-validator/screens.ts +4 -12
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/event-store.ts +23 -2
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/stack/table-helpers.ts +6 -0
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// createUncheckedSystemDb's grantedUnsafeRawRunner (db/tenant-db.ts) hands out a
|
|
2
|
+
// raw runner without a grant check. Under a resolved member principal that door
|
|
3
|
+
// is closed only because applyMemberResolutionReadOnly drops systemDb and
|
|
4
|
+
// dbOutsideTransaction — this pins that, plus the write surfaces it neutralizes.
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { HandlerContext } from "../../engine/types";
|
|
7
|
+
import { AccessDeniedError, FrameworkReasons } from "../../errors";
|
|
8
|
+
import { applyMemberResolutionReadOnly } from "../dispatch-shared";
|
|
9
|
+
|
|
10
|
+
const DROPPED_SURFACES = ["systemDb", "dbOutsideTransaction", "runPreSave", "files", "derivatives"];
|
|
11
|
+
|
|
12
|
+
const DENIED_CALLS = [
|
|
13
|
+
"write",
|
|
14
|
+
"writeAs",
|
|
15
|
+
"queryAs",
|
|
16
|
+
"appendEvent",
|
|
17
|
+
"unsafeAppendEvent",
|
|
18
|
+
"tryAppendEvent",
|
|
19
|
+
"fetchForWriting",
|
|
20
|
+
"archiveStream",
|
|
21
|
+
"restoreStream",
|
|
22
|
+
"snapshotAggregate",
|
|
23
|
+
"queryAsMember",
|
|
24
|
+
"resolveActiveMembership",
|
|
25
|
+
"notify",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function readOnlyContext(): Record<string, unknown> {
|
|
29
|
+
const wired = Object.fromEntries(
|
|
30
|
+
[...DROPPED_SURFACES, ...DENIED_CALLS, "scheduleAfterCommit"].map((key) => [
|
|
31
|
+
key,
|
|
32
|
+
() => "escalated",
|
|
33
|
+
]),
|
|
34
|
+
);
|
|
35
|
+
// @cast-boundary test stub — applyMemberResolutionReadOnly only spreads and overwrites.
|
|
36
|
+
const ctx = { ...wired, jobRunner: { handleEvent: () => "ran" } } as unknown as HandlerContext;
|
|
37
|
+
return applyMemberResolutionReadOnly(ctx) as unknown as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function surface(readOnly: Record<string, unknown>, key: string): unknown {
|
|
41
|
+
return readOnly[key];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("applyMemberResolutionReadOnly", () => {
|
|
45
|
+
test("drops every surface that could reach an unchecked system-scope db", () => {
|
|
46
|
+
const readOnly = readOnlyContext();
|
|
47
|
+
|
|
48
|
+
for (const key of DROPPED_SURFACES) {
|
|
49
|
+
expect(surface(readOnly, key)).toBeUndefined();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.each(DENIED_CALLS)("%s rejects with the member-resolution reason", async (call) => {
|
|
54
|
+
// @cast-boundary test stub — every denied surface is a call returning a promise.
|
|
55
|
+
const denied = surface(readOnlyContext(), call) as () => Promise<unknown>;
|
|
56
|
+
|
|
57
|
+
const error = await denied().then(
|
|
58
|
+
() => undefined,
|
|
59
|
+
(e: unknown) => e,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(error).toBeInstanceOf(AccessDeniedError);
|
|
63
|
+
// @cast-boundary narrowed by the assertion above.
|
|
64
|
+
expect((error as AccessDeniedError).details).toMatchObject({
|
|
65
|
+
reason: FrameworkReasons.memberResolutionReadOnly,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("scheduleAfterCommit and the jobRunner proxy throw synchronously", () => {
|
|
70
|
+
const readOnly = readOnlyContext();
|
|
71
|
+
// @cast-boundary test stub — sync surfaces, not promise-returning.
|
|
72
|
+
const scheduleAfterCommit = surface(readOnly, "scheduleAfterCommit") as () => void;
|
|
73
|
+
// @cast-boundary test stub — denyingJobRunnerProxy throws on any property read.
|
|
74
|
+
const jobRunner = surface(readOnly, "jobRunner") as { handleEvent: unknown };
|
|
75
|
+
|
|
76
|
+
expect(() => scheduleAfterCommit()).toThrow(AccessDeniedError);
|
|
77
|
+
expect(() => jobRunner.handleEvent).toThrow(AccessDeniedError);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -97,18 +97,21 @@ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugi
|
|
|
97
97
|
return usage.options;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
100
|
+
export function resolveTenantLifecyclePlugin(
|
|
101
|
+
registry: Registry,
|
|
102
|
+
caller: string,
|
|
103
|
+
): TenantLifecycleStatusPlugin | undefined {
|
|
101
104
|
const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
|
|
102
105
|
if (usages.length > 1) {
|
|
103
106
|
throw new InternalError({
|
|
104
|
-
message:
|
|
107
|
+
message: `${caller}: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
|
|
105
108
|
});
|
|
106
109
|
}
|
|
107
110
|
const usage = usages[0];
|
|
108
111
|
if (!usage) return undefined;
|
|
109
112
|
if (!isTenantLifecycleStatusPlugin(usage.options)) {
|
|
110
113
|
throw new InternalError({
|
|
111
|
-
message:
|
|
114
|
+
message: `${caller}: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
|
|
112
115
|
});
|
|
113
116
|
}
|
|
114
117
|
return usage.options;
|
|
@@ -160,7 +163,10 @@ export async function resolveActiveMembershipFn(
|
|
|
160
163
|
return { kind: "rejected", reason: "principal_blocked" };
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
const lifecyclePlugin =
|
|
166
|
+
const lifecyclePlugin = resolveTenantLifecyclePlugin(
|
|
167
|
+
registry,
|
|
168
|
+
"dispatcher.resolveActiveMembership",
|
|
169
|
+
);
|
|
164
170
|
if (lifecyclePlugin) {
|
|
165
171
|
const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
|
|
166
172
|
if (isTeardownRejected(lifecycle, policy)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
2
|
import type { DbRunner } from "../db/connection";
|
|
3
|
-
import { toKebab } from "../engine/qualified-name";
|
|
3
|
+
import { qnScope, toKebab } from "../engine/qualified-name";
|
|
4
4
|
import type { AppendEventArgs, Registry, TenantId } from "../engine/types";
|
|
5
5
|
import { InternalError, validationErrorFromZod } from "../errors";
|
|
6
6
|
import { isStreamArchived } from "../event-store/archive";
|
|
@@ -32,15 +32,6 @@ export type AppendDomainEventCoreDeps = {
|
|
|
32
32
|
readonly callerFeature?: string;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
// Extract the owning feature from a qualified event name. Events are
|
|
36
|
-
// registered as "<feature>:event:<short>" (see registry.ts qualify()) so the
|
|
37
|
-
// prefix before the first ":" is the owner. Falls back to undefined if the
|
|
38
|
-
// name isn't qualified — callers then skip the cross-feature check.
|
|
39
|
-
function eventOwnerFeature(qualifiedName: string): string | undefined {
|
|
40
|
-
const idx = qualifiedName.indexOf(":");
|
|
41
|
-
return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
35
|
// System-event prefix: events under this namespace bypass the registry +
|
|
45
36
|
// ownership checks. Reserved for framework-internal coordination (step-
|
|
46
37
|
// engine deferred dispatch, lifecycle signals). The matching MSP filters
|
|
@@ -71,7 +62,7 @@ export async function appendDomainEventCore(
|
|
|
71
62
|
// into kebab-case for the event/handler names (pubsub-orders:event:…) — so
|
|
72
63
|
// we compare the kebab form on both sides.
|
|
73
64
|
if (deps.callerFeature && !isSystemEvent) {
|
|
74
|
-
const owner =
|
|
65
|
+
const owner = qnScope(args.type);
|
|
75
66
|
const callerKebab = toKebab(deps.callerFeature);
|
|
76
67
|
if (owner && owner !== callerKebab) {
|
|
77
68
|
throw new InternalError({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { requestContext } from "../api/request-context";
|
|
1
|
+
import { requestContext, runWithOrigin } from "../api/request-context";
|
|
2
2
|
import type { SseBroker } from "../api/sse-broker";
|
|
3
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
4
4
|
import { runInSavepoint, selectMany } from "../db/query";
|
|
@@ -31,10 +31,9 @@ import type {
|
|
|
31
31
|
import { isRateLimitDisabled } from "../engine/types";
|
|
32
32
|
import type { TenantId } from "../engine/types/identifiers";
|
|
33
33
|
import {
|
|
34
|
-
AccessDeniedError,
|
|
35
34
|
FeatureDisabledError,
|
|
36
|
-
FrameworkReasons,
|
|
37
35
|
InternalError,
|
|
36
|
+
memberResolutionReadOnlyDenied,
|
|
38
37
|
VersionConflictError,
|
|
39
38
|
type WriteErrorInfo,
|
|
40
39
|
} from "../errors";
|
|
@@ -176,7 +175,7 @@ async function appendDomainEvent(
|
|
|
176
175
|
callerFeature: string | undefined,
|
|
177
176
|
): Promise<void> {
|
|
178
177
|
// Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
|
|
179
|
-
if (user
|
|
178
|
+
if (isMemberResolutionPrincipal(user)) throw memberResolutionReadOnlyDenied();
|
|
180
179
|
const { registry } = ctx;
|
|
181
180
|
const dbSource = resolveDbSource(ctx, tx);
|
|
182
181
|
if (!dbSource) {
|
|
@@ -222,12 +221,8 @@ function createSystemScopedDbGuard(
|
|
|
222
221
|
});
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
export function
|
|
226
|
-
return
|
|
227
|
-
message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
|
|
228
|
-
details: { reason: FrameworkReasons.memberResolutionReadOnly },
|
|
229
|
-
...(cause instanceof Error && { cause }),
|
|
230
|
-
});
|
|
224
|
+
export function isMemberResolutionPrincipal(user: SessionUser): boolean {
|
|
225
|
+
return user.origin === "member-resolution";
|
|
231
226
|
}
|
|
232
227
|
|
|
233
228
|
async function denyMemberResolutionWrite(): Promise<never> {
|
|
@@ -246,10 +241,11 @@ function denyingJobRunnerProxy(): JobRunnerRef {
|
|
|
246
241
|
|
|
247
242
|
// Every write/side-effect surface throws, so "no writeAsMember" holds even
|
|
248
243
|
// if a handler tries ctx.write directly on a resolved member principal.
|
|
249
|
-
function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
|
|
244
|
+
export function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
|
|
250
245
|
return {
|
|
251
246
|
...handlerContext,
|
|
252
|
-
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
247
|
+
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
248
|
+
// transaction, and its `memberReadOnly` grant denies every raw-runner handout.
|
|
253
249
|
dbOutsideTransaction: undefined,
|
|
254
250
|
write: denyMemberResolutionWrite,
|
|
255
251
|
writeAs: denyMemberResolutionWrite,
|
|
@@ -319,7 +315,12 @@ export async function buildHandlerContext(
|
|
|
319
315
|
context.tracer,
|
|
320
316
|
context.meter,
|
|
321
317
|
signal,
|
|
322
|
-
{
|
|
318
|
+
{
|
|
319
|
+
globalWrites: writeEscapeHatch,
|
|
320
|
+
unsafeRaw: handlerEscapeHatch,
|
|
321
|
+
report: reportEscapeHatch,
|
|
322
|
+
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
323
|
+
},
|
|
323
324
|
);
|
|
324
325
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
325
326
|
// the client has disconnected — handlers with many sequential queries skip
|
|
@@ -865,7 +866,7 @@ export async function buildHandlerContext(
|
|
|
865
866
|
} as HandlerContext; // @cast-boundary engine-bridge
|
|
866
867
|
|
|
867
868
|
// A resolved member principal is read-only by construction.
|
|
868
|
-
return user
|
|
869
|
+
return isMemberResolutionPrincipal(user)
|
|
869
870
|
? applyMemberResolutionReadOnly(handlerContext)
|
|
870
871
|
: handlerContext;
|
|
871
872
|
}
|
|
@@ -906,7 +907,12 @@ export async function runHandlerInstrumented<T>(
|
|
|
906
907
|
},
|
|
907
908
|
async (span) => {
|
|
908
909
|
try {
|
|
909
|
-
|
|
910
|
+
// #3043 — everything the handler writes, including entity-executor
|
|
911
|
+
// writes below it, is attributed to this handler.
|
|
912
|
+
const result = await runWithOrigin(
|
|
913
|
+
{ handler: type, feature: registry.getHandlerFeature(type) },
|
|
914
|
+
inner,
|
|
915
|
+
);
|
|
910
916
|
if (operation === "write" && isFailedWriteResult(result)) {
|
|
911
917
|
success = false;
|
|
912
918
|
errorClass = result.error?.code ?? "UnknownError";
|
|
@@ -962,11 +968,18 @@ export async function* runStreamInstrumented<T>(
|
|
|
962
968
|
attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
|
|
963
969
|
});
|
|
964
970
|
const it = inner();
|
|
971
|
+
// Each pull re-enters both scopes: AsyncLocalStorage does not survive a
|
|
972
|
+
// generator suspension, so wrapping inner() once would leave every event a
|
|
973
|
+
// stream writes unattributed.
|
|
974
|
+
const inScope = <R>(fn: () => R): R =>
|
|
975
|
+
runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
|
|
976
|
+
observabilityContext.run({ activeSpan: span }, fn),
|
|
977
|
+
);
|
|
965
978
|
try {
|
|
966
|
-
let next = await
|
|
979
|
+
let next = await inScope(() => it.next());
|
|
967
980
|
while (!next.done) {
|
|
968
981
|
yield next.value;
|
|
969
|
-
next = await
|
|
982
|
+
next = await inScope(() => it.next());
|
|
970
983
|
}
|
|
971
984
|
completedNormally = true;
|
|
972
985
|
return next.value;
|
|
@@ -979,7 +992,7 @@ export async function* runStreamInstrumented<T>(
|
|
|
979
992
|
} finally {
|
|
980
993
|
// forward close so inner()'s finally still fires — yield* did this for free
|
|
981
994
|
try {
|
|
982
|
-
await
|
|
995
|
+
await inScope(() => it.return?.(undefined));
|
|
983
996
|
} catch (closeError) {
|
|
984
997
|
// Only fold this in when nothing has already failed — a close-time
|
|
985
998
|
// error while an earlier error is in flight would mask the real
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { hasAccess } from "../engine/access";
|
|
2
2
|
import type { SessionUser } from "../engine/types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
memberResolutionReadOnlyDenied,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
validationErrorFromZod,
|
|
8
|
+
} from "../errors";
|
|
4
9
|
import { assertNoSecretLeak } from "../secrets";
|
|
5
10
|
import {
|
|
6
11
|
buildHandlerContext,
|
|
7
12
|
type DispatchContext,
|
|
8
13
|
enforceRateLimit,
|
|
9
14
|
ensureFeatureEnabled,
|
|
10
|
-
|
|
15
|
+
isMemberResolutionPrincipal,
|
|
11
16
|
runStreamInstrumented,
|
|
12
17
|
} from "./dispatch-shared";
|
|
13
18
|
|
|
@@ -38,7 +43,7 @@ async function* executeStreamInner(
|
|
|
38
43
|
|
|
39
44
|
// A resolved member principal (ctx.queryAsMember) is read-only — streams
|
|
40
45
|
// are excluded the same way executeWriteInner excludes writes.
|
|
41
|
-
if (user
|
|
46
|
+
if (isMemberResolutionPrincipal(user)) {
|
|
42
47
|
throw memberResolutionReadOnlyDenied();
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
FrameworkReasons,
|
|
16
16
|
InternalError,
|
|
17
17
|
isKumikoError,
|
|
18
|
+
memberResolutionReadOnlyDenied,
|
|
18
19
|
NotFoundError,
|
|
19
20
|
ValidationError,
|
|
20
21
|
validationErrorFromZod,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
CONFIG_WRITE_SET_TYPE,
|
|
29
30
|
checkFeatureEnabled,
|
|
30
31
|
enforceRateLimit,
|
|
31
|
-
|
|
32
|
+
isMemberResolutionPrincipal,
|
|
32
33
|
resolveDbSource,
|
|
33
34
|
runHandlerInstrumented,
|
|
34
35
|
TENANT_TIMEZONE_CONFIG_KEY,
|
|
@@ -371,7 +372,7 @@ async function executeWriteInner(
|
|
|
371
372
|
|
|
372
373
|
// Defense in depth — covers runBatch and a spread-copied user that kept
|
|
373
374
|
// `origin`, beyond buildHandlerContext's own ctx.write replacement.
|
|
374
|
-
if (user
|
|
375
|
+
if (isMemberResolutionPrincipal(user)) {
|
|
375
376
|
return writeFailure(memberResolutionReadOnlyDenied());
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../db/queries/event-consumer";
|
|
15
15
|
import { selectEventsHeadId } from "../db/queries/event-store";
|
|
16
16
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
17
|
+
import { qnScope } from "../engine/qualified-name";
|
|
17
18
|
import type { AppContext } from "../engine/types";
|
|
18
19
|
import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
|
|
19
20
|
import {
|
|
@@ -240,9 +241,20 @@ export async function deliverEvents(
|
|
|
240
241
|
const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
|
|
241
242
|
const causationId = String(stored.id);
|
|
242
243
|
const requestId = requestContext.generateId();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// #3043 — an event this apply writes is attributed to the consumer, not
|
|
245
|
+
// to whatever wrote the triggering event; causationId already links back.
|
|
246
|
+
await requestContext.run(
|
|
247
|
+
{
|
|
248
|
+
requestId,
|
|
249
|
+
correlationId,
|
|
250
|
+
causationId,
|
|
251
|
+
handler: consumer.name,
|
|
252
|
+
feature: consumer.featureName ?? qnScope(consumer.name),
|
|
253
|
+
},
|
|
254
|
+
async () => {
|
|
255
|
+
await consumer.handler(stored, context);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
246
258
|
cursor = row.id;
|
|
247
259
|
attempts = 0;
|
|
248
260
|
lastError = null;
|
|
@@ -127,6 +127,13 @@ export type EventDispatcher = {
|
|
|
127
127
|
// a full stop/start cycle. Production never needs this — start() runs
|
|
128
128
|
// it once on boot and the rows survive dispatcher lifetime.
|
|
129
129
|
ensureRegistered(): Promise<void>;
|
|
130
|
+
// Waits out a pass already in flight, without touching the timer or the
|
|
131
|
+
// LISTEN subscription — unlike stop(), the dispatcher keeps running
|
|
132
|
+
// afterwards. Test-teardown surface: a beforeEach/afterEach that resets
|
|
133
|
+
// the events table (TRUNCATE) must not race a pass this same dispatcher
|
|
134
|
+
// started (timer tick or NOTIFY wake-up) that is still writing into the
|
|
135
|
+
// tables the reset is about to wipe. No-op when nothing is in flight.
|
|
136
|
+
drain(): Promise<void>;
|
|
130
137
|
// Read-only view of the consumers this dispatcher is wired with. Exists
|
|
131
138
|
// for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
|
|
132
139
|
// the boot-validator (Welle 2.6.c coverage check: every registered MSP
|
|
@@ -253,6 +260,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
253
260
|
// pattern so behaviour under races stays predictable.
|
|
254
261
|
let passInFlight: Promise<DispatcherPassResult> | null = null;
|
|
255
262
|
|
|
263
|
+
async function drainPassInFlight(): Promise<void> {
|
|
264
|
+
if (passInFlight) {
|
|
265
|
+
await passInFlight.catch(() => {
|
|
266
|
+
// skip: errors already recorded per-consumer inside the pass
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
256
271
|
async function runOnce(): Promise<DispatcherPassResult> {
|
|
257
272
|
if (!preRegistered) {
|
|
258
273
|
throw new Error(
|
|
@@ -529,11 +544,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
// Drain any in-flight pass so shutdown observes consistent state.
|
|
532
|
-
|
|
533
|
-
await passInFlight.catch(() => {
|
|
534
|
-
// skip: errors already recorded per-consumer inside the pass
|
|
535
|
-
});
|
|
536
|
-
}
|
|
547
|
+
await drainPassInFlight();
|
|
537
548
|
// preRegistered stays true — the rows survive stop(). runOnce()
|
|
538
549
|
// after a stop() still works (tests stop the timer and then drain
|
|
539
550
|
// deterministically).
|
|
@@ -544,6 +555,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
544
555
|
preRegistered = true;
|
|
545
556
|
},
|
|
546
557
|
|
|
558
|
+
drain: drainPassInFlight,
|
|
559
|
+
|
|
547
560
|
runOnce,
|
|
548
561
|
};
|
|
549
562
|
}
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection";
|
|
2
2
|
import { extractPgError } from "../db/pg-error";
|
|
3
3
|
import { asRawClient, runInSavepoint, transaction } from "../db/query";
|
|
4
|
-
import { AccessDeniedError, InternalError } from "../errors";
|
|
5
|
-
import {
|
|
6
|
-
type DispatchContext,
|
|
7
|
-
memberResolutionReadOnlyDenied,
|
|
8
|
-
resolveDbSource,
|
|
9
|
-
} from "./dispatch-shared";
|
|
4
|
+
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
|
|
5
|
+
import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
|
|
10
6
|
|
|
11
7
|
const PG_READ_ONLY_SQLSTATE = "25006";
|
|
12
8
|
|
|
@@ -37,7 +37,7 @@ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
|
|
|
37
37
|
return createEntity({
|
|
38
38
|
table: "fwbootguard_memos",
|
|
39
39
|
fields: {
|
|
40
|
-
ownerId: createTextField({ required: true }),
|
|
40
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
41
41
|
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
42
42
|
},
|
|
43
43
|
...(access ? { access } : {}),
|
|
@@ -146,6 +146,12 @@ export async function resetEventStore(
|
|
|
146
146
|
stack: { db: unknown; eventDispatcher?: EventDispatcher },
|
|
147
147
|
extraTables: readonly (unknown | string)[] = [],
|
|
148
148
|
): Promise<void> {
|
|
149
|
+
// A pass the dispatcher's own timer/LISTEN already started (from a
|
|
150
|
+
// preceding test) may still be writing into the tables truncated below —
|
|
151
|
+
// drain it first so the TRUNCATE never races a write it didn't cause.
|
|
152
|
+
if (stack.eventDispatcher) {
|
|
153
|
+
await stack.eventDispatcher.drain();
|
|
154
|
+
}
|
|
149
155
|
const frameworkTables = [
|
|
150
156
|
"kumiko_events",
|
|
151
157
|
"kumiko_event_consumers",
|
|
@@ -97,6 +97,56 @@ describe("generateE2ESpec", () => {
|
|
|
97
97
|
]);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
test("groups-only sections liefern Required-Felder, Fill-Ops und Text-Assertion", () => {
|
|
101
|
+
const feature = defineFeature("tasks", (r) => {
|
|
102
|
+
r.systemScope();
|
|
103
|
+
r.entity("task", taskEntity);
|
|
104
|
+
r.writeHandler(
|
|
105
|
+
defineEntityCreateHandler("task", taskEntity, {
|
|
106
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
r.screen({
|
|
110
|
+
id: "task-list",
|
|
111
|
+
type: "entityList",
|
|
112
|
+
entity: "task",
|
|
113
|
+
columns: ["title", "status", "done"],
|
|
114
|
+
});
|
|
115
|
+
r.screen({
|
|
116
|
+
id: "task-edit",
|
|
117
|
+
type: "entityEdit",
|
|
118
|
+
entity: "task",
|
|
119
|
+
layout: {
|
|
120
|
+
sections: [
|
|
121
|
+
{
|
|
122
|
+
title: "tasks:section.basics",
|
|
123
|
+
fields: [],
|
|
124
|
+
groups: [
|
|
125
|
+
{ title: "tasks:group.core", fields: ["title", "status"] },
|
|
126
|
+
{ title: "tasks:group.state", fields: ["done"] },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
const specs = generateE2ESpec(createRegistry([feature]));
|
|
134
|
+
const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
|
|
135
|
+
|
|
136
|
+
const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
|
|
137
|
+
if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
|
|
138
|
+
expect(validates.requiredFields).toEqual(["title"]);
|
|
139
|
+
|
|
140
|
+
const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
|
|
141
|
+
if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
|
|
142
|
+
expect(persists.fills).toEqual([
|
|
143
|
+
{ kind: "fill", field: "title", value: "e2e title" },
|
|
144
|
+
{ kind: "select", field: "status", value: "todo" },
|
|
145
|
+
{ kind: "check", field: "done", value: true },
|
|
146
|
+
]);
|
|
147
|
+
expect(persists.identifyingField).toBe("title");
|
|
148
|
+
});
|
|
149
|
+
|
|
100
150
|
test("date emittiert fill, timestamp wird übersprungen (zwei Inputs seit #369)", () => {
|
|
101
151
|
const entity = createEntity({
|
|
102
152
|
table: "events",
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
parseQn,
|
|
37
37
|
qn,
|
|
38
38
|
type Registry,
|
|
39
|
+
sectionFieldSpecs,
|
|
39
40
|
toKebab,
|
|
40
41
|
} from "../engine";
|
|
41
42
|
|
|
@@ -249,7 +250,7 @@ function collectRequiredEditFields(
|
|
|
249
250
|
const out: string[] = [];
|
|
250
251
|
for (const section of screen.layout.sections) {
|
|
251
252
|
if (!isFieldsEditSection(section)) continue;
|
|
252
|
-
for (const rawField of section
|
|
253
|
+
for (const rawField of sectionFieldSpecs(section)) {
|
|
253
254
|
const { field } = normalizeEditField(rawField);
|
|
254
255
|
const def = entity.fields[field];
|
|
255
256
|
if (def && "required" in def && def.required === true) {
|
|
@@ -287,7 +288,7 @@ function buildEditFillOps(
|
|
|
287
288
|
const ops: EditFillOp[] = [];
|
|
288
289
|
for (const section of screen.layout.sections) {
|
|
289
290
|
if (!isFieldsEditSection(section)) continue;
|
|
290
|
-
for (const raw of section
|
|
291
|
+
for (const raw of sectionFieldSpecs(section)) {
|
|
291
292
|
const { field } = normalizeEditField(raw);
|
|
292
293
|
const def = entity.fields[field];
|
|
293
294
|
if (!def) continue;
|
|
@@ -335,7 +336,7 @@ function pickIdentifyingForEdit(
|
|
|
335
336
|
|
|
336
337
|
for (const section of editScreen.layout.sections) {
|
|
337
338
|
if (!isFieldsEditSection(section)) continue;
|
|
338
|
-
for (const rawField of section
|
|
339
|
+
for (const rawField of sectionFieldSpecs(section)) {
|
|
339
340
|
const { field } = normalizeEditField(rawField);
|
|
340
341
|
if (entity.fields[field]?.type !== "text") continue;
|
|
341
342
|
if (columns && !columns.has(field)) continue;
|
package/src/ui-types/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type { DerivedCellRoundingTarget } from "../engine/embedded-derived";
|
|
|
25
25
|
export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived";
|
|
26
26
|
export type { ParsedRefTarget } from "../engine/parse-ref-target";
|
|
27
27
|
export { parseRefTarget } from "../engine/parse-ref-target";
|
|
28
|
+
export type { FieldsOrGroupsSection } from "../engine/screen-helpers";
|
|
28
29
|
export {
|
|
29
30
|
evalFieldCondition,
|
|
30
31
|
explicitListScreenId,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
normalizeEditField,
|
|
36
37
|
normalizeListColumn,
|
|
37
38
|
resolveNavParentScreen,
|
|
39
|
+
sectionFieldSpecs,
|
|
38
40
|
} from "../engine/screen-helpers";
|
|
39
41
|
// Entity + field types. EntityDefinition is the canonical shape that
|
|
40
42
|
// view-model builders iterate; FieldDefinition is the per-field union
|