@cosmicdrift/kumiko-framework 0.338.0 → 0.340.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/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +54 -10
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.js +29 -6
- package/dist/api/server.js +9 -1
- package/dist/bun-db/index.d.ts +2 -2
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +25 -1
- package/dist/bun-db/query.js +144 -17
- package/dist/crypto/event-pii.js +13 -4
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
- package/dist/crypto/pii-ciphertext-format.js +11 -0
- package/dist/crypto/pii-field-encryption.d.ts +4 -2
- package/dist/crypto/pii-field-encryption.js +50 -21
- package/dist/crypto/system-event-pii.js +2 -2
- package/dist/db/queries/backfill-pii.js +4 -0
- package/dist/db/query-api.d.ts +1 -1
- package/dist/db/query.d.ts +1 -1
- package/dist/db/tenant-db.js +16 -12
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +19 -0
- package/dist/engine/boot-validator/pii-retention.js +30 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/extensions/user-data.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/feature-manifest.d.ts +7 -0
- package/dist/engine/feature-manifest.js +8 -0
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +9 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/retry.js +0 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +1 -1
- package/dist/engine/steps/wait-for-event.js +0 -1
- package/dist/engine/steps/wait.js +0 -4
- package/dist/engine/steps/workflow-system-events.d.ts +2 -0
- package/dist/engine/steps/workflow-system-events.js +89 -0
- package/dist/pipeline/append-event-core.d.ts +3 -1
- package/dist/pipeline/append-event-core.js +8 -4
- package/dist/pipeline/dispatch-shared.js +5 -2
- package/package.json +4 -4
- package/src/changes.json +48 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Registered-event declarations for the `kumiko:system:workflow.*` run-stream
|
|
2
|
+
// events. The names stay the literal system types stored in kumiko_events;
|
|
3
|
+
// registering them makes projections (r.extendEntityProjection / r.projection
|
|
4
|
+
// with source `workflow-run`) valid apply-key targets and lists them in the
|
|
5
|
+
// registry's event catalog.
|
|
6
|
+
//
|
|
7
|
+
// Appends of these types keep bypassing schema validation (append-event-core):
|
|
8
|
+
// the schemas here document the payloads the writers append and tolerate the
|
|
9
|
+
// legacy shapes still stored in old streams.
|
|
10
|
+
import * as z from "zod";
|
|
11
|
+
import { SYSTEM_EVENT_PII_STANCES } from "../../crypto/system-event-pii.js";
|
|
12
|
+
import { WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./_step-dispatch-constants.js";
|
|
13
|
+
const triggerEventRefSchema = z.object({
|
|
14
|
+
eventId: z.string(),
|
|
15
|
+
aggregateId: z.string(),
|
|
16
|
+
version: z.number().int(),
|
|
17
|
+
});
|
|
18
|
+
// Optional on every step event: runs started before the Q7 fingerprint existed
|
|
19
|
+
// have none.
|
|
20
|
+
const stepContext = {
|
|
21
|
+
workflowName: z.string(),
|
|
22
|
+
stepIndex: z.number().int(),
|
|
23
|
+
triggerEventType: z.string(),
|
|
24
|
+
definitionFingerprint: z.string().optional(),
|
|
25
|
+
};
|
|
26
|
+
const WORKFLOW_SYSTEM_EVENT_SCHEMAS = [
|
|
27
|
+
[
|
|
28
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
29
|
+
z.object({
|
|
30
|
+
workflowName: z.string(),
|
|
31
|
+
triggerEventType: z.string(),
|
|
32
|
+
// Current streams reference the trigger event; streams written before
|
|
33
|
+
// #3474 carry a copy of its payload instead.
|
|
34
|
+
triggerEventRef: triggerEventRefSchema.optional(),
|
|
35
|
+
triggerPayload: z.unknown().optional(),
|
|
36
|
+
definitionFingerprint: z.string().optional(),
|
|
37
|
+
idempotencyKey: z.string().optional(),
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
42
|
+
z.object({ workflowName: z.string(), stepIndex: z.number().int() }),
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
46
|
+
z.object({
|
|
47
|
+
workflowName: z.string(),
|
|
48
|
+
stepIndex: z.number().int(),
|
|
49
|
+
error: z.string(),
|
|
50
|
+
reason: z.string().optional(),
|
|
51
|
+
}),
|
|
52
|
+
],
|
|
53
|
+
[WORKFLOW_WAITING_TYPE, z.object({ ...stepContext, wakeAt: z.string() })],
|
|
54
|
+
[
|
|
55
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
56
|
+
z.object({
|
|
57
|
+
...stepContext,
|
|
58
|
+
eventType: z.string(),
|
|
59
|
+
match: z.record(z.string(), z.unknown()).optional(),
|
|
60
|
+
timeoutAt: z.string(),
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
WORKFLOW_RESUMED_TYPE,
|
|
65
|
+
z.object({ stepIndex: z.number().int(), retryAttempt: z.number().int().optional() }),
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
69
|
+
z.object({
|
|
70
|
+
...stepContext,
|
|
71
|
+
attempt: z.number().int(),
|
|
72
|
+
maxAttempts: z.number().int(),
|
|
73
|
+
wakeAt: z.string(),
|
|
74
|
+
error: z.string(),
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
];
|
|
78
|
+
function buildWorkflowSystemEventDefs() {
|
|
79
|
+
const defs = new Map();
|
|
80
|
+
for (const [name, schema] of WORKFLOW_SYSTEM_EVENT_SCHEMAS) {
|
|
81
|
+
const piiFields = SYSTEM_EVENT_PII_STANCES.get(name);
|
|
82
|
+
if (piiFields === undefined) {
|
|
83
|
+
throw new Error(`Workflow system event "${name}" has no entry in SYSTEM_EVENT_PII_STANCES`);
|
|
84
|
+
}
|
|
85
|
+
defs.set(name, { name, schema, version: 1, piiFields });
|
|
86
|
+
}
|
|
87
|
+
return defs;
|
|
88
|
+
}
|
|
89
|
+
export const WORKFLOW_SYSTEM_EVENT_DEFS = buildWorkflowSystemEventDefs();
|
|
@@ -9,4 +9,6 @@ export type AppendDomainEventCoreDeps = {
|
|
|
9
9
|
readonly callSiteLabel: string;
|
|
10
10
|
readonly callerFeature?: string;
|
|
11
11
|
};
|
|
12
|
-
export declare function appendDomainEventCore(deps: AppendDomainEventCoreDeps, args: AppendEventArgs
|
|
12
|
+
export declare function appendDomainEventCore(deps: AppendDomainEventCoreDeps, args: AppendEventArgs & {
|
|
13
|
+
readonly expectedVersion?: number;
|
|
14
|
+
}): Promise<StoredEvent>;
|
|
@@ -33,10 +33,12 @@ export async function appendDomainEventCore(deps, args) {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
// System events skip schema validation
|
|
37
|
-
//
|
|
36
|
+
// System events skip schema validation even when registered (workflow
|
|
37
|
+
// events are, so projections can apply them): payload shape is owned by the
|
|
38
|
+
// framework step that emits + the bundled MSP that consumes, and stored
|
|
39
|
+
// streams keep legacy shapes. Parsing would also strip unknown keys.
|
|
38
40
|
let validatedPayload;
|
|
39
|
-
if (eventDef) {
|
|
41
|
+
if (eventDef && !isSystemEvent) {
|
|
40
42
|
const parsed = eventDef.schema.safeParse(args.payload ?? {});
|
|
41
43
|
if (!parsed.success)
|
|
42
44
|
throw validationErrorFromZod(parsed.error);
|
|
@@ -53,7 +55,9 @@ export async function appendDomainEventCore(deps, args) {
|
|
|
53
55
|
}
|
|
54
56
|
// Stream-version authoritative. See Block 0 / getStreamVersion doc for
|
|
55
57
|
// why row.version isn't sufficient once ctx.appendEvent enters the picture.
|
|
56
|
-
|
|
58
|
+
// A caller-pinned version (fetchForWriting handles) must reach append() so a
|
|
59
|
+
// write landing between fetch and append conflicts instead of being re-read.
|
|
60
|
+
const expectedVersion = args.expectedVersion ?? (await getStreamVersion(deps.db, args.aggregateId, deps.tenantId));
|
|
57
61
|
const reqCtx = requestContext.get();
|
|
58
62
|
// metadata.requestId is a plain trace marker — no uniqueness constraint,
|
|
59
63
|
// every event of the request carries it. HTTP-level idempotency runs in
|
|
@@ -399,8 +399,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
399
399
|
currentVersion: fetchedVersion,
|
|
400
400
|
});
|
|
401
401
|
}
|
|
402
|
-
//
|
|
403
|
-
//
|
|
402
|
+
// Pinned into every append: a write landing between fetch and append
|
|
403
|
+
// fails with VersionConflictError instead of stacking on a state the
|
|
404
|
+
// caller's business rules never saw. Bumps per appendOne so consecutive
|
|
405
|
+
// appends stay in order without re-reading the DB.
|
|
404
406
|
let handleVersion = fetchedVersion;
|
|
405
407
|
const appendOne = async (appendArgs) => {
|
|
406
408
|
await appendDomainEvent(ctx, {
|
|
@@ -408,6 +410,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
408
410
|
aggregateType: args.aggregateType,
|
|
409
411
|
type: appendArgs.type,
|
|
410
412
|
payload: appendArgs.payload,
|
|
413
|
+
expectedVersion: handleVersion,
|
|
411
414
|
}, user, tx, registry.getHandlerFeature(type));
|
|
412
415
|
handleVersion += 1;
|
|
413
416
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.340.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.340.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.340.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.340.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,52 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Typed bun-db helpers for insert-ignore, inner joins, jsonb text filters and key-ordered aggregates",
|
|
6
|
+
"detail": "`insertOnConflictDoNothing(db, table, values, { conflictKeys? })` returns the inserted row, or `undefined` when the conflict target already exists. `selectInnerJoin(db, { left, right, on, leftWhere?, rightWhere?, leftFields?, rightFields?, limit? })` joins two tables on one or more column pairs (casting to text only when the column types differ) and returns `{ left, right }` rows; `rightFields` defaults to none, so secrets on the joined table are only read when named. The where operator `jsonText: { keys, eq }` compares `col->>key` on jsonb columns, with several keys combined through `COALESCE`. `aggregateWhere` accepts `orderByKeys: \"asc\" | \"desc\"` to sort groups by their key before `limit`. All identifiers come from the table metadata and all values are bound parameters. The helpers refuse tenant-scoped tables like the other raw-connection helpers; use the `TenantDb` methods there.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.339.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Write handlers can declare tenantlessAnonymous",
|
|
13
|
+
"detail": "`r.writeHandler({ ..., tenantlessAnonymous: true })` lets an anonymous `POST /api/write` for exactly that handler run under `SYSTEM_TENANT_ID` where the anonymous middleware would otherwise answer `400 tenant_required` (resolver silent, no client tenant). It never applies to `/api/batch`, to a client-supplied `X-Tenant` or `kumiko_tenant`, to `tenant_mismatch` or when a tenant resolved; `tenantExists` and the lifecycle gate are skipped for the system tenant. Boot rejects the flag unless `access.roles` is exactly `[\"anonymous\"]` and a real `rateLimit` (not `{ disabled: true }`) is declared. `authMiddleware` gets the option `isTenantlessAnonymousWrite`, which `buildServer` builds from the registry.",
|
|
14
|
+
"migration": "keine"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.339.0",
|
|
18
|
+
"type": "breaking",
|
|
19
|
+
"title": "fetchForWriting handles reject appends after a concurrent write",
|
|
20
|
+
"migration": "Appends through a ctx.fetchForWriting handle now use the version the handle was read at. When two requests write the same aggregate concurrently, the later append no longer stacks on top silently: the request fails with 409 version_conflict (no automatic retry), and its transaction rolls back. Tests that fire concurrent writes on one aggregate and expected both to succeed must expect exactly one success. Clients should treat 409 version_conflict like a stale form: reload and retry."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.339.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "PII encryption for jsonb fields",
|
|
26
|
+
"detail": "`createJsonbField({ personal: \"tenant\" })` (or `\"self\"` / `{ of }`) is encrypted per subject like a text field, so crypto-shredding applies: after the key is erased, list and detail return `[[erased]]` for the field. The ciphertext uses the self-describing format `kumiko-pii:v3:` (v2 plus a JSON-typed plaintext, AAD-bound to subject and field), so a string, number or object round-trips by type. Existing plaintext objects, arrays and strings are read as-is and are not re-encrypted; they become ciphertext on the next write of the field, and the backfill skips jsonb fields. There is no search, filter, sort or index inside an encrypted jsonb field: boot rejects `personal` on a jsonb field that is in an entity index, has `sortable`, `filterable`, `searchable` or `lookupable`, or is the custom-fields `customFields` column. Event payload fields declared in `piiFields` may now be objects or arrays; they are encrypted in the same JSON format. Non-jsonb fields still require a string.",
|
|
27
|
+
"migration": "Runtimes before this release cannot read `kumiko-pii:v3:` ciphertext. Before annotating a jsonb field with `personal`, roll this version out to every instance, so a rolling deploy never has an old instance reading a value a new one wrote."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.339.0",
|
|
31
|
+
"type": "fix",
|
|
32
|
+
"title": "Uncaught route errors and client aborts go through one error handler",
|
|
33
|
+
"detail": "`buildServer` calls `app.onError(handleUncaughtRouteError)`, which reuses the `/api/query` helpers: a rethrown abort of the request signal maps to 499, everything else is classified with `toKumikoError`, logged with `logServerFault` and serialized with `serializeError`. Sub-apps mounted via `app.route()` inherit it.",
|
|
34
|
+
"migration": "keine"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"version": "0.339.0",
|
|
38
|
+
"type": "breaking",
|
|
39
|
+
"title": "ctx.db.unsafeRaw() takes no reason; the audit uses the declared escapeHatch reason",
|
|
40
|
+
"detail": "`TenantDb.unsafeRaw` is now `unsafeRaw(): DbRunner`. The `unsafe-raw` audit entry carries the reason from the `escapeHatch` declaration, as before. Calls without a matching declaration are still rejected with an `AccessDeniedError`.",
|
|
41
|
+
"migration": "Replace `ctx.db.unsafeRaw(\"...\")` with `ctx.db.unsafeRaw()`. The reason lives only in `escapeHatch: { reason }` on the handler, hook or job; TypeScript flags old calls. `ctx.systemDb.unsafeRaw(reason)` is unchanged."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"version": "0.339.0",
|
|
45
|
+
"type": "improvement",
|
|
46
|
+
"title": "Workflow run-stream events are registered, so projections can apply them",
|
|
47
|
+
"detail": "`WORKFLOW_SYSTEM_EVENT_DEFS` declares the seven `kumiko:system:workflow.*` events with a payload schema, version 1 and the stance from `SYSTEM_EVENT_PII_STANCES`. `createRegistry` seeds them into the event map next to `r.defineEvent` events, so apply-key validation, the PII catalog and the upcaster chain see them. A typo such as `kumiko:system:workflow.run-faild` still fails boot. `appendDomainEventCore` keeps skipping schema validation for `kumiko:system:*` types. The feature manifest gains an optional top-level `systemEvents` list.",
|
|
48
|
+
"migration": "A projection whose row ids are not the workflow-run aggregate id (for example uuidv5-derived ids) still cannot pass the ghost-row guard and needs its own change: its row id must equal the workflow-run aggregate id (this applies to kumiko-enterprise's run-state projection). Hand-built `FeatureManifest` objects stay valid because `systemEvents` is optional; regenerate committed manifests with `bun run gen:manifest` in `samples/apps/use-all-bundled` to pick the list up."
|
|
49
|
+
},
|
|
2
50
|
{
|
|
3
51
|
"version": "0.338.0",
|
|
4
52
|
"type": "improvement",
|