@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +92 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -71,7 +71,7 @@ import type {
|
|
|
71
71
|
AgentHandlerHints,
|
|
72
72
|
ClaimKeyType,
|
|
73
73
|
EscapeHatchDeclaration,
|
|
74
|
-
|
|
74
|
+
RateLimitDeclaration,
|
|
75
75
|
} from "../types/handlers";
|
|
76
76
|
import type { HookPhase } from "../types/hooks";
|
|
77
77
|
import type { HttpRouteMethod } from "../types/http-route";
|
|
@@ -81,6 +81,7 @@ import type { RelationDefinition } from "../types/relations";
|
|
|
81
81
|
import type { ScreenDefinition } from "../types/screen";
|
|
82
82
|
import type { TreeActionDef } from "../types/tree-node";
|
|
83
83
|
import type { WorkspaceDefinition } from "../types/workspace";
|
|
84
|
+
import type { RawRefSentinel } from "./extractors/shared";
|
|
84
85
|
import type { SourceLocation } from "./source-location";
|
|
85
86
|
|
|
86
87
|
// =============================================================================
|
|
@@ -367,6 +368,10 @@ export type ScreenPattern = {
|
|
|
367
368
|
// overrides that derivation explicitly (force-show or force-hide);
|
|
368
369
|
// `agent.risk` ("low" | "mid" | "high") classifies the action's blast
|
|
369
370
|
// radius, defaulting to "mid" for a write handler.
|
|
371
|
+
//
|
|
372
|
+
// access/rateLimit/escapeHatch/agent additionally accept a RawRefSentinel
|
|
373
|
+
// for a non-literal value (imported/same-file const, or a sub-value like
|
|
374
|
+
// `personalData: PD`) that would otherwise lose the reference on render.
|
|
370
375
|
export type WriteHandlerPattern = {
|
|
371
376
|
readonly kind: "writeHandler";
|
|
372
377
|
readonly source: SourceLocation;
|
|
@@ -384,12 +389,12 @@ export type WriteHandlerPattern = {
|
|
|
384
389
|
// handlerBody: the closure body as source text. Always opaque — AI
|
|
385
390
|
// generates raw TypeScript, no DSL interpretation.
|
|
386
391
|
readonly handlerBody?: SourceLocation;
|
|
387
|
-
readonly access?: AccessRule;
|
|
392
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
388
393
|
readonly description?: string;
|
|
389
|
-
readonly agent?: AgentHandlerHints;
|
|
390
|
-
readonly rateLimit?:
|
|
394
|
+
readonly agent?: AgentHandlerHints | RawRefSentinel;
|
|
395
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
391
396
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
392
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
397
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
393
398
|
};
|
|
394
399
|
|
|
395
400
|
// `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
|
|
@@ -408,25 +413,27 @@ export type QueryHandlerPattern = {
|
|
|
408
413
|
readonly handlerName?: string;
|
|
409
414
|
readonly schemaSource?: SourceLocation;
|
|
410
415
|
readonly handlerBody?: SourceLocation;
|
|
411
|
-
readonly access?: AccessRule;
|
|
416
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
412
417
|
readonly description?: string;
|
|
413
|
-
readonly agent?: AgentHandlerHints;
|
|
414
|
-
readonly rateLimit?:
|
|
415
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
418
|
+
readonly agent?: AgentHandlerHints | RawRefSentinel;
|
|
419
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
420
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
416
421
|
};
|
|
417
422
|
|
|
418
423
|
// `r.streamHandler(...)` — registers a streaming read handler: name, Zod
|
|
419
|
-
// input schema, async-generator handler closure, plus optional `access
|
|
420
|
-
// `rateLimit` rules
|
|
421
|
-
// case as
|
|
424
|
+
// input schema, async-generator handler closure, plus optional `access`,
|
|
425
|
+
// `rateLimit`, and `escapeHatch` rules (StreamHandlerDef carries all three
|
|
426
|
+
// at runtime). Same header/body split and opaque single-reference case as
|
|
427
|
+
// QueryHandlerPattern.
|
|
422
428
|
export type StreamHandlerPattern = {
|
|
423
429
|
readonly kind: "streamHandler";
|
|
424
430
|
readonly source: SourceLocation;
|
|
425
431
|
readonly handlerName?: string;
|
|
426
432
|
readonly schemaSource?: SourceLocation;
|
|
427
433
|
readonly handlerBody?: SourceLocation;
|
|
428
|
-
readonly access?: AccessRule;
|
|
429
|
-
readonly rateLimit?:
|
|
434
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
435
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
436
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
430
437
|
};
|
|
431
438
|
|
|
432
439
|
// `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
|
|
@@ -447,7 +454,7 @@ export type HookPattern = {
|
|
|
447
454
|
readonly target: string | readonly string[] | { readonly allOf: string };
|
|
448
455
|
readonly fnBody: SourceLocation;
|
|
449
456
|
readonly phase?: HookPhase;
|
|
450
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
457
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
451
458
|
};
|
|
452
459
|
|
|
453
460
|
// `r.job(name, options, handler)` — registers a background job, qualified
|
|
@@ -436,6 +436,7 @@ function renderStreamHandler(p: StreamHandlerPattern): string {
|
|
|
436
436
|
lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
|
|
437
437
|
if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
|
|
438
438
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
439
|
+
if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
|
|
439
440
|
lines.push("});");
|
|
440
441
|
return lines.join("\n");
|
|
441
442
|
}
|
|
@@ -229,9 +229,9 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
229
229
|
useExtension(
|
|
230
230
|
extensionNameOrDefinition:
|
|
231
231
|
| string
|
|
232
|
-
| ({ readonly name: string; readonly entity: NameOrRef } &
|
|
232
|
+
| ({ readonly name: string; readonly entity: NameOrRef } & object),
|
|
233
233
|
entityRef?: NameOrRef,
|
|
234
|
-
options?:
|
|
234
|
+
options?: object,
|
|
235
235
|
): void {
|
|
236
236
|
const [extensionName, resolvedEntityRef, resolvedOptions] =
|
|
237
237
|
typeof extensionNameOrDefinition === "string"
|
|
@@ -241,11 +241,12 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
241
241
|
return [name, entity, rest] as const;
|
|
242
242
|
})();
|
|
243
243
|
const resolvedEntityName = resolveName(resolvedEntityRef);
|
|
244
|
+
// @cast-boundary engine-bridge — typed per-extension options → erased registration bag
|
|
245
|
+
const optionsBag = resolvedOptions as Record<string, unknown> | undefined;
|
|
244
246
|
// fw#2914 — cross-cutting escapeHatch convention for hook-context db
|
|
245
|
-
// access (mirrors r.hook's validation above).
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
const escapeHatch = resolvedOptions?.["escapeHatch"];
|
|
247
|
+
// access (mirrors r.hook's validation above). Validated the same way
|
|
248
|
+
// for every extension regardless of its typed hook shape.
|
|
249
|
+
const escapeHatch = optionsBag?.["escapeHatch"];
|
|
249
250
|
if (escapeHatch !== undefined) {
|
|
250
251
|
const reason =
|
|
251
252
|
typeof escapeHatch === "object" && escapeHatch !== null
|
|
@@ -261,7 +262,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
261
262
|
state.extensionUsages.push({
|
|
262
263
|
extensionName,
|
|
263
264
|
entityName: resolvedEntityName,
|
|
264
|
-
options:
|
|
265
|
+
options: optionsBag,
|
|
265
266
|
});
|
|
266
267
|
},
|
|
267
268
|
extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
|
package/src/engine/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ export {
|
|
|
98
98
|
export { declareEscapeHatch } from "./escape-hatch-declaration";
|
|
99
99
|
export type { EmitCtx } from "./event-helpers";
|
|
100
100
|
export { emitEvent, typedPayload } from "./event-helpers";
|
|
101
|
-
export type { KumikoExtensionName } from "./extension-names";
|
|
101
|
+
export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names";
|
|
102
102
|
export {
|
|
103
103
|
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
104
104
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
@@ -124,11 +124,18 @@ export type {
|
|
|
124
124
|
StorageProviderExtensionHooks,
|
|
125
125
|
StorageProviderHookCtx,
|
|
126
126
|
} from "./extensions/storage-provider";
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
export {
|
|
128
|
+
isTenantDataExtensionHooks,
|
|
129
|
+
type TenantDataDestroyHook,
|
|
130
|
+
type TenantDataExtensionHooks,
|
|
131
|
+
type TenantDataHookCtx,
|
|
131
132
|
} from "./extensions/tenant-data";
|
|
133
|
+
export {
|
|
134
|
+
isTenantResourceExtensionHooks,
|
|
135
|
+
type TenantResourceDestroyHook,
|
|
136
|
+
type TenantResourceExtensionHooks,
|
|
137
|
+
type TenantResourceHookCtx,
|
|
138
|
+
} from "./extensions/tenant-resource";
|
|
132
139
|
export type {
|
|
133
140
|
TenantUserModel,
|
|
134
141
|
UserDataDeleteHook,
|
|
@@ -136,6 +143,7 @@ export type {
|
|
|
136
143
|
UserDataExportHook,
|
|
137
144
|
UserDataExportSnippet,
|
|
138
145
|
UserDataExtensionHooks,
|
|
146
|
+
UserDataExtensionOptions,
|
|
139
147
|
UserDataHookCtx,
|
|
140
148
|
UserDataStorageProvider,
|
|
141
149
|
} from "./extensions/user-data";
|
|
@@ -178,10 +186,13 @@ export type {
|
|
|
178
186
|
FormFieldLabel,
|
|
179
187
|
FormFieldSpec,
|
|
180
188
|
FormInputType,
|
|
189
|
+
HandlerHeaderUpdate,
|
|
181
190
|
ParseError,
|
|
182
191
|
ParseResult,
|
|
183
192
|
PatternCategory,
|
|
184
193
|
PatternChange,
|
|
194
|
+
PatternChangeIssue,
|
|
195
|
+
PatternChangesParseResult,
|
|
185
196
|
PatternFormSchema,
|
|
186
197
|
PatternId,
|
|
187
198
|
RenderFeatureFileInput,
|
|
@@ -195,11 +206,13 @@ export {
|
|
|
195
206
|
groupByCategory,
|
|
196
207
|
PATTERN_LIBRARY,
|
|
197
208
|
parseFeatureFile,
|
|
209
|
+
parsePatternChanges,
|
|
198
210
|
parseSourceFile,
|
|
199
211
|
removePattern,
|
|
200
212
|
renderFeatureFile,
|
|
201
213
|
renderPattern,
|
|
202
214
|
replacePattern,
|
|
215
|
+
updatePattern,
|
|
203
216
|
VERSION_HEADER,
|
|
204
217
|
} from "./feature-ast";
|
|
205
218
|
export {
|
|
@@ -392,6 +405,8 @@ export type {
|
|
|
392
405
|
EscapeHatchTarget,
|
|
393
406
|
EscapeHatchUseEvent,
|
|
394
407
|
EventDef,
|
|
408
|
+
ExtensionOptionsArgs,
|
|
409
|
+
ExtensionOptionsFor,
|
|
395
410
|
FeatureDefinition,
|
|
396
411
|
FeatureRegistrar,
|
|
397
412
|
FieldAccess,
|
|
@@ -419,6 +434,7 @@ export type {
|
|
|
419
434
|
JsonbFieldDef,
|
|
420
435
|
KumikoEntityTypeMap,
|
|
421
436
|
KumikoEventTypeMap,
|
|
437
|
+
KumikoExtensionOptionsMap,
|
|
422
438
|
KumikoHandlerPayloadMap,
|
|
423
439
|
KumikoHandlerResultMap,
|
|
424
440
|
LifecycleHookType,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
@@ -72,6 +72,12 @@ export type {
|
|
|
72
72
|
KumikoHandlerPayloadMap,
|
|
73
73
|
KumikoHandlerResultMap,
|
|
74
74
|
} from "@cosmicdrift/kumiko-types/event-type-map";
|
|
75
|
+
// Cross-Feature Compile-Time-Type-Map for r.useExtension options — mirrors KumikoEventTypeMap above.
|
|
76
|
+
export type {
|
|
77
|
+
ExtensionOptionsArgs,
|
|
78
|
+
ExtensionOptionsFor,
|
|
79
|
+
KumikoExtensionOptionsMap,
|
|
80
|
+
} from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
75
81
|
export type {
|
|
76
82
|
BootCheckContext,
|
|
77
83
|
BootCheckFn,
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
// and stay readable without the new fields.
|
|
15
15
|
|
|
16
16
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
-
import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
17
|
+
import { UNATTRIBUTED_ORIGIN, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
20
20
|
import { selectMany } from "../../db/query";
|
|
21
21
|
import { buildEntityTable } from "../../db/table-builder";
|
|
22
22
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
23
|
+
import type { TenantId } from "../../engine/types";
|
|
23
24
|
import {
|
|
24
25
|
resetEventStore,
|
|
25
26
|
setupTestStack,
|
|
@@ -43,8 +44,11 @@ const orderTable = buildEntityTable("attr-order", orderEntity);
|
|
|
43
44
|
|
|
44
45
|
const PLACED = "attribution:event:placed";
|
|
45
46
|
const CONFIRMED = "attribution:event:confirmed";
|
|
47
|
+
const PROBED = "attribution:event:probed";
|
|
46
48
|
const PLACE_HANDLER = "attribution:write:order:place";
|
|
49
|
+
const PROBE_HANDLER = "attribution:write:order:probe";
|
|
47
50
|
const CONFIRMER_MSP = "attribution:projection:confirmer";
|
|
51
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
48
52
|
|
|
49
53
|
const attributionFeature = defineFeature("attribution", (r) => {
|
|
50
54
|
r.entity("attr-order", orderEntity);
|
|
@@ -75,6 +79,26 @@ const attributionFeature = defineFeature("attribution", (r) => {
|
|
|
75
79
|
{ access: { roles: ["Admin"] } },
|
|
76
80
|
);
|
|
77
81
|
|
|
82
|
+
const probed = r.defineEvent("probed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
83
|
+
|
|
84
|
+
// Anonymous, no public-intake declared, no PII field written (nothing to gate).
|
|
85
|
+
r.writeHandler(
|
|
86
|
+
"order:probe",
|
|
87
|
+
z.object({ item: z.string() }),
|
|
88
|
+
async (event, ctx) => {
|
|
89
|
+
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
90
|
+
if (!created.isSuccess) return created;
|
|
91
|
+
await ctx.unsafeAppendEvent({
|
|
92
|
+
aggregateId: String(created.data.id),
|
|
93
|
+
aggregateType: "attr-order",
|
|
94
|
+
type: probed.name,
|
|
95
|
+
payload: { orderId: String(created.data.id) },
|
|
96
|
+
});
|
|
97
|
+
return created;
|
|
98
|
+
},
|
|
99
|
+
{ access: { roles: ["anonymous"] } },
|
|
100
|
+
);
|
|
101
|
+
|
|
78
102
|
r.multiStreamProjection({
|
|
79
103
|
name: "confirmer",
|
|
80
104
|
apply: {
|
|
@@ -95,7 +119,11 @@ let stack: TestStack;
|
|
|
95
119
|
const admin = TestUsers.admin;
|
|
96
120
|
|
|
97
121
|
beforeAll(async () => {
|
|
98
|
-
stack = await setupTestStack({
|
|
122
|
+
stack = await setupTestStack({
|
|
123
|
+
features: [attributionFeature],
|
|
124
|
+
systemHooks: [],
|
|
125
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
126
|
+
});
|
|
99
127
|
await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
|
|
100
128
|
});
|
|
101
129
|
|
|
@@ -107,7 +135,7 @@ afterEach(async () => {
|
|
|
107
135
|
await resetEventStore(stack, ["read_attribution_orders"]);
|
|
108
136
|
});
|
|
109
137
|
|
|
110
|
-
type Origin = { feature?: string; handler?: string };
|
|
138
|
+
type Origin = { feature?: string; handler?: string; writeOrigin?: WriteOrigin };
|
|
111
139
|
|
|
112
140
|
async function originOf(type: string): Promise<Origin> {
|
|
113
141
|
const rows = await selectMany(stack.db, eventsTable);
|
|
@@ -165,6 +193,28 @@ describe("#3043 — event attribution from the execution scope", () => {
|
|
|
165
193
|
});
|
|
166
194
|
});
|
|
167
195
|
|
|
196
|
+
test("authenticated write: the event carries no writeOrigin at all", async () => {
|
|
197
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "cog" }, admin);
|
|
198
|
+
|
|
199
|
+
const origin = await originOf(PLACED);
|
|
200
|
+
expect(origin.writeOrigin).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("anonymous non-intake write: the event is stamped with the gated origin", async () => {
|
|
204
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
205
|
+
type: PROBE_HANDLER,
|
|
206
|
+
payload: { item: "washer" },
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
|
|
210
|
+
const origin = await originOf(PROBED);
|
|
211
|
+
expect(origin.writeOrigin).toMatchObject({
|
|
212
|
+
rootHandler: PROBE_HANDLER,
|
|
213
|
+
anonymousRoot: true,
|
|
214
|
+
publicIntake: false,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
168
218
|
test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
|
|
169
219
|
const aggregateId = uuid();
|
|
170
220
|
await appendRaw(stack.db, {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { DbRunner } from "../db";
|
|
11
11
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
12
|
+
import { claimXactId } from "../db/queries/event-store";
|
|
12
13
|
import {
|
|
13
14
|
eventPredecessorExists,
|
|
14
15
|
findExistingEventVersion,
|
|
@@ -54,6 +55,9 @@ export async function appendRaw(runner: DbRunner, event: RawEventToAppend): Prom
|
|
|
54
55
|
const eventVersion = event.eventVersion ?? 1;
|
|
55
56
|
|
|
56
57
|
try {
|
|
58
|
+
// See db/queries/event-store.ts's claimXactId — gap-finality needs a
|
|
59
|
+
// real xact id assigned before this holder's insert.
|
|
60
|
+
await claimXactId(runner);
|
|
57
61
|
if (event.expectedVersion === 0) {
|
|
58
62
|
await insertRawFirst(runner, event, newVersion, eventVersion);
|
|
59
63
|
} else {
|
|
@@ -145,6 +149,7 @@ export async function appendRawBatch(
|
|
|
145
149
|
});
|
|
146
150
|
|
|
147
151
|
try {
|
|
152
|
+
await claimXactId(runner);
|
|
148
153
|
await insertRawEventBatch(runner, valuesClauses.join(", "), params);
|
|
149
154
|
} catch (e) {
|
|
150
155
|
if (isUniqueViolation(e)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type EventMetadata,
|
|
3
|
+
isPersonalDataGated,
|
|
3
4
|
type StoredEvent,
|
|
4
5
|
UNATTRIBUTED_ORIGIN,
|
|
5
6
|
} from "@cosmicdrift/kumiko-types/event-store-types";
|
|
@@ -15,6 +16,7 @@ import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
|
15
16
|
import type { DbRunner } from "../db";
|
|
16
17
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
17
18
|
import {
|
|
19
|
+
claimXactId,
|
|
18
20
|
insertSubsequentEventRow,
|
|
19
21
|
notifyPgChannel,
|
|
20
22
|
selectAggregateMaxVersion,
|
|
@@ -93,14 +95,16 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
93
95
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
94
96
|
|
|
95
97
|
try {
|
|
98
|
+
await claimXactId(db);
|
|
99
|
+
|
|
96
100
|
const row =
|
|
97
101
|
toStore.expectedVersion === 0
|
|
98
102
|
? await insertFirstEvent(db, toStore, newVersion, eventVersion)
|
|
99
103
|
: await insertSubsequentEvent(db, toStore, newVersion, eventVersion);
|
|
100
104
|
|
|
101
|
-
// NOTIFY
|
|
102
|
-
//
|
|
103
|
-
//
|
|
105
|
+
// NOTIFY after the INSERT: outside a transaction each statement commits
|
|
106
|
+
// on its own, so a NOTIFY sent first would wake the dispatcher before
|
|
107
|
+
// the row exists.
|
|
104
108
|
await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
|
|
105
109
|
|
|
106
110
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
@@ -127,12 +131,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
127
131
|
// bypass this deliberately — they replay historical rows verbatim.
|
|
128
132
|
function stampOrigin(event: EventToAppend): EventToAppend {
|
|
129
133
|
const origin = requestContext.get();
|
|
134
|
+
const { writeOrigin: _droppedCallerWriteOrigin, ...restMetadata } = event.metadata;
|
|
130
135
|
return {
|
|
131
136
|
...event,
|
|
132
137
|
metadata: {
|
|
133
|
-
...
|
|
138
|
+
...restMetadata,
|
|
134
139
|
feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
|
|
135
140
|
handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
|
|
141
|
+
...(origin?.writeOrigin &&
|
|
142
|
+
isPersonalDataGated(origin.writeOrigin) && { writeOrigin: origin.writeOrigin }),
|
|
136
143
|
},
|
|
137
144
|
};
|
|
138
145
|
}
|
|
@@ -380,9 +387,11 @@ export async function loadAllEventsByType(
|
|
|
380
387
|
|
|
381
388
|
// Stream every event for an aggregate_type across all tenants, batchwise
|
|
382
389
|
// instead of buffered. Memory-bounded: never more than `batchSize` rows
|
|
383
|
-
// resident. Cursor walks `events.id` (bigserial monotonic
|
|
384
|
-
//
|
|
385
|
-
//
|
|
390
|
+
// resident. Cursor walks `events.id` (bigserial monotonic, but ids are
|
|
391
|
+
// assigned at INSERT time, not commit time — a sweep can overtake a still-
|
|
392
|
+
// open transaction and its lower id, same gap the event-dispatcher's
|
|
393
|
+
// pending_gaps tracks). Fine for this generator's use case: history sweeps
|
|
394
|
+
// (projection-rebuild, tests) that don't need live, just-committed rows.
|
|
386
395
|
//
|
|
387
396
|
// Use case: projection-rebuild on a large event log (>100k events per
|
|
388
397
|
// aggregate-type). loadAllEventsByType would OOM; this iterator yields
|