@cosmicdrift/kumiko-framework 0.289.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 +18 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
- 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 +3 -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/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-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/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/screens.ts +4 -12
- package/src/engine/index.ts +1 -0
- package/src/engine/qualified-name.ts +9 -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/event-store.ts +23 -2
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +17 -5
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- 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
|
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
|
|
|
20
20
|
// hook) writes it. secretNote's ownership rule checks authorId, so
|
|
21
21
|
// create only succeeds if the hook ran BEFORE the field-ownership check
|
|
22
22
|
// (kumiko-framework#1672 — see also event-store-executor-write.ts).
|
|
23
|
-
authorId: createTextField(),
|
|
24
|
-
secretNote: createTextField({
|
|
23
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
24
|
+
secretNote: createTextField({
|
|
25
|
+
personal: false,
|
|
26
|
+
reason: "test_fixture",
|
|
27
|
+
access: { write: { User: from("user:id", "authorId") } },
|
|
28
|
+
}),
|
|
25
29
|
},
|
|
26
30
|
});
|
|
27
31
|
|
|
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("encrypted + sensitive flags type-allowed", () => {
|
|
44
|
-
const f = createLongTextField({
|
|
44
|
+
const f = createLongTextField({
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "test_fixture",
|
|
47
|
+
encrypted: true,
|
|
48
|
+
sensitive: true,
|
|
49
|
+
});
|
|
45
50
|
expect(f.encrypted).toBe(true);
|
|
46
51
|
expect(f.sensitive).toBe(true);
|
|
47
52
|
});
|
|
@@ -7,9 +7,19 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { defineFeature } from "../../define-feature";
|
|
9
9
|
import { createEntity, createTextField } from "../../factories";
|
|
10
|
-
import type { FeatureDefinition } from "../../types";
|
|
10
|
+
import type { FeatureDefinition, TextFieldDef } from "../../types";
|
|
11
11
|
import { validateRecordOwnedSubjects } from "../record-owned";
|
|
12
12
|
|
|
13
|
+
// Presence/absence of the annotation is the test variable here; after #2810
|
|
14
|
+
// the factory can no longer produce the unannotated shape.
|
|
15
|
+
const unannotatedText: TextFieldDef = {
|
|
16
|
+
type: "text",
|
|
17
|
+
maxLength: 200,
|
|
18
|
+
required: false,
|
|
19
|
+
searchable: false,
|
|
20
|
+
sortable: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
function featureWith(
|
|
14
24
|
idType: "serial" | "uuid" | undefined,
|
|
15
25
|
withRecordOwnedField: boolean,
|
|
@@ -23,7 +33,7 @@ function featureWith(
|
|
|
23
33
|
fields: {
|
|
24
34
|
body: withRecordOwnedField
|
|
25
35
|
? createTextField({ personal: { of: "id" }, find: "none" })
|
|
26
|
-
:
|
|
36
|
+
: { ...unannotatedText },
|
|
27
37
|
},
|
|
28
38
|
}),
|
|
29
39
|
);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
isFieldsEditSection,
|
|
4
4
|
normalizeEditField,
|
|
5
5
|
normalizeListColumn,
|
|
6
|
+
sectionFieldSpecs,
|
|
6
7
|
} from "../screen-helpers";
|
|
7
8
|
import type {
|
|
8
9
|
EditFieldSpec,
|
|
@@ -125,7 +126,7 @@ function validateEditLayoutNoFunctions(
|
|
|
125
126
|
continue;
|
|
126
127
|
}
|
|
127
128
|
if (!isFieldsEditSection(section)) continue;
|
|
128
|
-
for (const fieldSpec of section
|
|
129
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
129
130
|
validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
|
|
130
131
|
}
|
|
131
132
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
normalizeEditField,
|
|
17
17
|
normalizeListColumn,
|
|
18
18
|
resolveNavParentScreen,
|
|
19
|
+
sectionFieldSpecs,
|
|
19
20
|
} from "../screen-helpers";
|
|
20
21
|
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
21
22
|
import { metricField } from "../types";
|
|
@@ -608,15 +609,6 @@ function validateFieldsXorGroups(
|
|
|
608
609
|
}
|
|
609
610
|
}
|
|
610
611
|
|
|
611
|
-
function flattenFieldsOrGroups(section: {
|
|
612
|
-
readonly fields: readonly EditFieldSpec[];
|
|
613
|
-
readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
|
|
614
|
-
}): readonly EditFieldSpec[] {
|
|
615
|
-
return section.groups !== undefined
|
|
616
|
-
? section.groups.flatMap((group) => group.fields)
|
|
617
|
-
: section.fields;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
612
|
function validateFormLayoutSections(
|
|
621
613
|
featureName: string,
|
|
622
614
|
screenId: string,
|
|
@@ -657,7 +649,7 @@ function validateFormLayoutSections(
|
|
|
657
649
|
);
|
|
658
650
|
}
|
|
659
651
|
validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
|
|
660
|
-
for (const fieldSpec of
|
|
652
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
661
653
|
const normalized = normalizeEditField(fieldSpec);
|
|
662
654
|
if (!fieldNames.has(normalized.field)) {
|
|
663
655
|
throw new Error(
|
|
@@ -1420,7 +1412,7 @@ export function validateScreens(
|
|
|
1420
1412
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
|
|
1421
1413
|
section,
|
|
1422
1414
|
);
|
|
1423
|
-
for (const fieldSpec of
|
|
1415
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
1424
1416
|
const normalized = normalizeEditField(fieldSpec);
|
|
1425
1417
|
if (!fieldNames.has(normalized.field)) {
|
|
1426
1418
|
throw new Error(
|
|
@@ -1837,7 +1829,7 @@ export function validateScreens(
|
|
|
1837
1829
|
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
|
|
1838
1830
|
section,
|
|
1839
1831
|
);
|
|
1840
|
-
for (const fieldSpec of
|
|
1832
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
1841
1833
|
const normalized = normalizeEditField(fieldSpec);
|
|
1842
1834
|
if (!fieldNames.has(normalized.field)) {
|
|
1843
1835
|
throw new Error(
|
package/src/engine/index.ts
CHANGED
|
@@ -266,6 +266,7 @@ export {
|
|
|
266
266
|
isWriteFormEditSection,
|
|
267
267
|
normalizeEditField,
|
|
268
268
|
normalizeListColumn,
|
|
269
|
+
sectionFieldSpecs,
|
|
269
270
|
} from "./screen-helpers";
|
|
270
271
|
export type { TransitionGraph } from "./state-machine";
|
|
271
272
|
export { defineTransitions, guardTransition } from "./state-machine";
|
|
@@ -130,6 +130,15 @@ export function isKebabSegment(name: string): boolean {
|
|
|
130
130
|
return QN_SEGMENT.test(name);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Owning scope of a qualified name — the segment before the first ":".
|
|
134
|
+
// Deliberately lenient where parseQn() throws: callers here hold names that
|
|
135
|
+
// may be unqualified (system consumers, ad-hoc jobs) and want undefined, not
|
|
136
|
+
// an exception.
|
|
137
|
+
export function qnScope(qualifiedName: string): string | undefined {
|
|
138
|
+
const idx = qualifiedName.indexOf(":");
|
|
139
|
+
return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
// Build a fully-qualified entity name from a feature name + QN type + short
|
|
134
143
|
// name, running both names through toKebab first. This is the canonical
|
|
135
144
|
// "how the registry qualifies things" helper — both createRegistry and
|
|
@@ -42,7 +42,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
|
|
|
42
42
|
const contactEntity = createEntity({
|
|
43
43
|
fields: {
|
|
44
44
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
45
|
-
displayName: createTextField(),
|
|
45
|
+
displayName: createTextField({ personal: false, reason: "test_fixture" }),
|
|
46
46
|
},
|
|
47
47
|
});
|
|
48
48
|
const contactTable = buildEntityTable("contact", contactEntity);
|
|
@@ -82,7 +82,7 @@ const signalsFeature = defineFeature("signals", (r) => {
|
|
|
82
82
|
// absent from an old event's payload, unlike contact.email (self-subject via row id).
|
|
83
83
|
const noteEntity = createEntity({
|
|
84
84
|
fields: {
|
|
85
|
-
authorId: createTextField(),
|
|
85
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
86
86
|
body: createTextField({ personal: { of: "authorId" }, find: "none" }),
|
|
87
87
|
},
|
|
88
88
|
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// #3043 — event attribution. append() stamps metadata.feature +
|
|
2
|
+
// metadata.handler from the ambient execution scope, below every envelope
|
|
3
|
+
// builder, so no writer can forget it.
|
|
4
|
+
//
|
|
5
|
+
// Claims pinned here:
|
|
6
|
+
// 1. Dispatch path: an event a write-handler appends carries the handler's
|
|
7
|
+
// qualified name and its owning feature.
|
|
8
|
+
// 2. In-process path: the entity-executor's CRUD event carries the SAME
|
|
9
|
+
// attribution although nothing was passed through its signature.
|
|
10
|
+
// 3. MSP-apply: an event an apply writes is attributed to the consumer, not
|
|
11
|
+
// to the handler that started the chain.
|
|
12
|
+
// 4. No scope: a bare append() stamps the sentinel, never a guess.
|
|
13
|
+
// 5. appendRaw stays unstamped — historical rows keep their metadata verbatim
|
|
14
|
+
// and stay readable without the new fields.
|
|
15
|
+
|
|
16
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
+
import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
20
|
+
import { selectMany } from "../../db/query";
|
|
21
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
22
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
23
|
+
import {
|
|
24
|
+
resetEventStore,
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
TestUsers,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
} from "../../stack";
|
|
30
|
+
import { generateId as uuid } from "../../utils";
|
|
31
|
+
import { appendRaw } from "../admin-api";
|
|
32
|
+
import { append } from "../event-store";
|
|
33
|
+
import { eventsTable } from "../events-schema";
|
|
34
|
+
|
|
35
|
+
const orderEntity = createEntity({
|
|
36
|
+
table: "read_attribution_orders",
|
|
37
|
+
fields: {
|
|
38
|
+
item: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const orderTable = buildEntityTable("attr-order", orderEntity);
|
|
43
|
+
|
|
44
|
+
const PLACED = "attribution:event:placed";
|
|
45
|
+
const CONFIRMED = "attribution:event:confirmed";
|
|
46
|
+
const PLACE_HANDLER = "attribution:write:order:place";
|
|
47
|
+
const CONFIRMER_MSP = "attribution:projection:confirmer";
|
|
48
|
+
|
|
49
|
+
const attributionFeature = defineFeature("attribution", (r) => {
|
|
50
|
+
r.entity("attr-order", orderEntity);
|
|
51
|
+
|
|
52
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
53
|
+
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
|
|
54
|
+
piiFields: "none",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
58
|
+
entityName: "attr-order",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
"order:place",
|
|
63
|
+
z.object({ item: z.string() }),
|
|
64
|
+
async (event, ctx) => {
|
|
65
|
+
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
66
|
+
if (!created.isSuccess) return created;
|
|
67
|
+
await ctx.unsafeAppendEvent({
|
|
68
|
+
aggregateId: String(created.data.id),
|
|
69
|
+
aggregateType: "attr-order",
|
|
70
|
+
type: placed.name,
|
|
71
|
+
payload: { orderId: String(created.data.id) },
|
|
72
|
+
});
|
|
73
|
+
return created;
|
|
74
|
+
},
|
|
75
|
+
{ access: { roles: ["Admin"] } },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
r.multiStreamProjection({
|
|
79
|
+
name: "confirmer",
|
|
80
|
+
apply: {
|
|
81
|
+
[placed.name]: async (event, _tx, ctx) => {
|
|
82
|
+
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
83
|
+
await ctx.unsafeAppendEvent({
|
|
84
|
+
aggregateId: event.aggregateId,
|
|
85
|
+
aggregateType: "attr-order",
|
|
86
|
+
type: confirmed.name,
|
|
87
|
+
payload: { orderId: event.aggregateId },
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let stack: TestStack;
|
|
95
|
+
const admin = TestUsers.admin;
|
|
96
|
+
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
stack = await setupTestStack({ features: [attributionFeature], systemHooks: [] });
|
|
99
|
+
await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(async () => {
|
|
103
|
+
await stack.cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await resetEventStore(stack, ["read_attribution_orders"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
type Origin = { feature?: string; handler?: string };
|
|
111
|
+
|
|
112
|
+
async function originOf(type: string): Promise<Origin> {
|
|
113
|
+
const rows = await selectMany(stack.db, eventsTable);
|
|
114
|
+
const row = rows.find((r: Record<string, unknown>) => r["type"] === type);
|
|
115
|
+
expect(row).toBeDefined();
|
|
116
|
+
return (row?.["metadata"] ?? {}) as Origin;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("#3043 — event attribution from the execution scope", () => {
|
|
120
|
+
test("dispatch path: ctx.appendEvent stamps the handler and its feature", async () => {
|
|
121
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "widget" }, admin);
|
|
122
|
+
|
|
123
|
+
expect(await originOf(PLACED)).toMatchObject({
|
|
124
|
+
feature: "attribution",
|
|
125
|
+
handler: PLACE_HANDLER,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("in-process path: the entity-executor CRUD event carries the same attribution", async () => {
|
|
130
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "sprocket" }, admin);
|
|
131
|
+
|
|
132
|
+
// Nothing is threaded through createEntityExecutor → EventStoreExecutor.write —
|
|
133
|
+
// the write runs inside the handler's scope, so the stamp finds it anyway.
|
|
134
|
+
expect(await originOf("attr-order.created")).toMatchObject({
|
|
135
|
+
feature: "attribution",
|
|
136
|
+
handler: PLACE_HANDLER,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("MSP-apply: the follow-up event is attributed to the consumer, not the trigger", async () => {
|
|
141
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "gasket" }, admin);
|
|
142
|
+
await stack.eventDispatcher?.runOnce();
|
|
143
|
+
|
|
144
|
+
expect(await originOf(CONFIRMED)).toMatchObject({
|
|
145
|
+
feature: "attribution",
|
|
146
|
+
handler: CONFIRMER_MSP,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no scope: a bare append() stamps the sentinel instead of guessing", async () => {
|
|
151
|
+
const aggregateId = uuid();
|
|
152
|
+
await append(stack.db, {
|
|
153
|
+
aggregateId,
|
|
154
|
+
aggregateType: "attr-order",
|
|
155
|
+
tenantId: admin.tenantId,
|
|
156
|
+
expectedVersion: 0,
|
|
157
|
+
type: PLACED,
|
|
158
|
+
payload: { orderId: aggregateId },
|
|
159
|
+
metadata: { userId: admin.id },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(await originOf(PLACED)).toMatchObject({
|
|
163
|
+
feature: UNATTRIBUTED_ORIGIN,
|
|
164
|
+
handler: UNATTRIBUTED_ORIGIN,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
|
|
169
|
+
const aggregateId = uuid();
|
|
170
|
+
await appendRaw(stack.db, {
|
|
171
|
+
aggregateId,
|
|
172
|
+
aggregateType: "attr-order",
|
|
173
|
+
tenantId: admin.tenantId,
|
|
174
|
+
expectedVersion: 0,
|
|
175
|
+
type: CONFIRMED,
|
|
176
|
+
payload: { orderId: aggregateId },
|
|
177
|
+
metadata: { userId: admin.id },
|
|
178
|
+
createdAt: Temporal.Instant.from("2023-01-15T10:00:00Z"),
|
|
179
|
+
createdBy: admin.id,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const origin = await originOf(CONFIRMED);
|
|
183
|
+
expect(origin.feature).toBeUndefined();
|
|
184
|
+
expect(origin.handler).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type EventMetadata,
|
|
3
|
+
type StoredEvent,
|
|
4
|
+
UNATTRIBUTED_ORIGIN,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
6
|
// Value-only import, aliased to avoid shadowing the ambient global
|
|
3
7
|
// `Temporal` TYPE this file's other Temporal.Instant annotations resolve
|
|
4
8
|
// against (StoredEvent.createdAt et al. — importing the bare name here
|
|
@@ -6,6 +10,7 @@ import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event
|
|
|
6
10
|
// as a runtime value on globalThis, so the un-aliased call below crashed
|
|
7
11
|
// with "Temporal is not defined" outside boot paths that install it (#1480).
|
|
8
12
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
13
|
+
import { requestContext } from "../api/request-context";
|
|
9
14
|
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
10
15
|
import type { DbRunner } from "../db";
|
|
11
16
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
@@ -83,7 +88,7 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
83
88
|
aggregateType: event.aggregateType,
|
|
84
89
|
aggregateId: event.aggregateId,
|
|
85
90
|
});
|
|
86
|
-
const toStore = payload === event.payload ? event : { ...event, payload };
|
|
91
|
+
const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
|
|
87
92
|
const newVersion = toStore.expectedVersion + 1;
|
|
88
93
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
89
94
|
|
|
@@ -116,6 +121,22 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
// #3043 — attribution is derived from the execution scope, never taken from
|
|
125
|
+
// the caller: a passed-in value can lie about who wrote the row, a derived
|
|
126
|
+
// one cannot. Hence overwrite rather than merge. appendRaw/appendRawBatch
|
|
127
|
+
// bypass this deliberately — they replay historical rows verbatim.
|
|
128
|
+
function stampOrigin(event: EventToAppend): EventToAppend {
|
|
129
|
+
const origin = requestContext.get();
|
|
130
|
+
return {
|
|
131
|
+
...event,
|
|
132
|
+
metadata: {
|
|
133
|
+
...event.metadata,
|
|
134
|
+
feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
|
|
135
|
+
handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
type InsertReturn = { id: bigint; createdAt: Temporal.Instant };
|
|
120
141
|
|
|
121
142
|
async function insertFirstEvent(
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { requestContext } from "../api/request-context";
|
|
|
4
4
|
import type { DbConnection, DbRow } from "../db/connection";
|
|
5
5
|
import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
|
|
6
6
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
7
|
+
import { qnScope } from "../engine/qualified-name";
|
|
7
8
|
import { createSystemUser } from "../engine/system-user";
|
|
8
9
|
import {
|
|
9
10
|
type AppContext,
|
|
@@ -665,8 +666,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
665
666
|
|
|
666
667
|
const runInSpan = async (): Promise<void> => {
|
|
667
668
|
try {
|
|
668
|
-
await requestContext.run(
|
|
669
|
-
|
|
669
|
+
await requestContext.run(
|
|
670
|
+
{
|
|
671
|
+
requestId: jobRequestId,
|
|
672
|
+
correlationId: jobCorrelationId,
|
|
673
|
+
// #3043 — events a job writes carry the job as their origin.
|
|
674
|
+
handler: jobName,
|
|
675
|
+
feature: qnScope(jobName),
|
|
676
|
+
},
|
|
677
|
+
() => jobDef.handler(payload, jobContext),
|
|
670
678
|
);
|
|
671
679
|
const duration = Date.now() - startTime;
|
|
672
680
|
await options.onJobComplete?.(jobName, jobId, duration, logs);
|
|
@@ -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";
|
|
@@ -907,7 +907,12 @@ export async function runHandlerInstrumented<T>(
|
|
|
907
907
|
},
|
|
908
908
|
async (span) => {
|
|
909
909
|
try {
|
|
910
|
-
|
|
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
|
+
);
|
|
911
916
|
if (operation === "write" && isFailedWriteResult(result)) {
|
|
912
917
|
success = false;
|
|
913
918
|
errorClass = result.error?.code ?? "UnknownError";
|
|
@@ -963,11 +968,18 @@ export async function* runStreamInstrumented<T>(
|
|
|
963
968
|
attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
|
|
964
969
|
});
|
|
965
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
|
+
);
|
|
966
978
|
try {
|
|
967
|
-
let next = await
|
|
979
|
+
let next = await inScope(() => it.next());
|
|
968
980
|
while (!next.done) {
|
|
969
981
|
yield next.value;
|
|
970
|
-
next = await
|
|
982
|
+
next = await inScope(() => it.next());
|
|
971
983
|
}
|
|
972
984
|
completedNormally = true;
|
|
973
985
|
return next.value;
|
|
@@ -980,7 +992,7 @@ export async function* runStreamInstrumented<T>(
|
|
|
980
992
|
} finally {
|
|
981
993
|
// forward close so inner()'s finally still fires — yield* did this for free
|
|
982
994
|
try {
|
|
983
|
-
await
|
|
995
|
+
await inScope(() => it.return?.(undefined));
|
|
984
996
|
} catch (closeError) {
|
|
985
997
|
// Only fold this in when nothing has already failed — a close-time
|
|
986
998
|
// error while an earlier error is in flight would mask the real
|
|
@@ -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;
|
|
@@ -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 } : {}),
|
|
@@ -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",
|