@cosmicdrift/kumiko-framework 0.393.0 → 0.394.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.
Files changed (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/agents/examples/cross-feature-events.md +87 -0
  3. package/agents/examples/dashboard-stats.md +141 -2
  4. package/agents/examples/env-composition.md +7 -7
  5. package/agents/examples/inbound-mail-triage.md +12 -0
  6. package/agents/examples/pdf-audit-export.md +103 -50
  7. package/agents/features.md +15 -13
  8. package/dist/api/entity-read-access.d.ts +1 -0
  9. package/dist/api/entity-read-access.js +20 -5
  10. package/dist/api/server-event-dispatcher.js +23 -2
  11. package/dist/compliance/profiles.js +4 -4
  12. package/dist/crypto/index.d.ts +1 -0
  13. package/dist/crypto/index.js +1 -0
  14. package/dist/crypto/kms-outage.d.ts +8 -0
  15. package/dist/crypto/kms-outage.js +33 -0
  16. package/dist/crypto/pii-field-encryption.js +4 -3
  17. package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
  18. package/dist/engine/boot-validator/entity-handler.js +2 -16
  19. package/dist/engine/boot-validator/query-output-columns.js +7 -0
  20. package/dist/engine/boot-validator/query-refs.js +9 -0
  21. package/dist/engine/boot-validator/screens-actions.js +19 -0
  22. package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
  23. package/dist/engine/boot-validator/screens-dashboard.js +13 -6
  24. package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
  25. package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
  26. package/dist/engine/boot-validator/screens-lists.js +3 -0
  27. package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
  28. package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
  29. package/dist/engine/boot-validator/transaction-phases.js +28 -0
  30. package/dist/engine/define-handler.d.ts +3 -2
  31. package/dist/engine/define-handler.js +3 -0
  32. package/dist/engine/factories.d.ts +2 -0
  33. package/dist/engine/feature-config-events-jobs.js +3 -0
  34. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  35. package/dist/engine/feature-entity-handlers.js +1 -17
  36. package/dist/engine/handler-definition-slots.d.ts +17 -0
  37. package/dist/engine/handler-definition-slots.js +20 -0
  38. package/dist/engine/qualified-name.d.ts +2 -1
  39. package/dist/engine/qualified-name.js +2 -14
  40. package/dist/engine/registry-facade.js +6 -0
  41. package/dist/engine/registry-read-models.d.ts +2 -0
  42. package/dist/engine/registry-read-models.js +24 -0
  43. package/dist/engine/registry-state.d.ts +4 -0
  44. package/dist/engine/registry-state.js +1 -0
  45. package/dist/engine/registry-validate.js +12 -1
  46. package/dist/engine/registry.js +2 -0
  47. package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
  48. package/dist/events-cli.d.ts +19 -0
  49. package/dist/events-cli.js +121 -0
  50. package/dist/files/bind-pending-file-refs.js +2 -1
  51. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  52. package/dist/files/can-caller-read-file-ref.d.ts +10 -0
  53. package/dist/files/can-caller-read-file-ref.js +12 -0
  54. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  55. package/dist/files/create-file-ref-from-bytes.js +7 -2
  56. package/dist/files/create-file-ref.d.ts +4 -12
  57. package/dist/files/create-file-ref.js +2 -45
  58. package/dist/files/default-file-guard.d.ts +4 -0
  59. package/dist/files/default-file-guard.js +41 -0
  60. package/dist/files/delete-record-file-refs.d.ts +8 -0
  61. package/dist/files/delete-record-file-refs.js +20 -0
  62. package/dist/files/file-routes.d.ts +1 -0
  63. package/dist/files/file-routes.js +9 -36
  64. package/dist/files/index.d.ts +6 -2
  65. package/dist/files/index.js +4 -1
  66. package/dist/files/record-only-file-entity-types.d.ts +2 -0
  67. package/dist/files/record-only-file-entity-types.js +22 -0
  68. package/dist/files/record-read-check.js +11 -0
  69. package/dist/files/types.d.ts +2 -1
  70. package/dist/files/types.js +1 -1
  71. package/dist/files/upload-field-resolution.d.ts +24 -0
  72. package/dist/files/upload-field-resolution.js +51 -0
  73. package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
  74. package/dist/jobs/job-runner-event-triggers.js +64 -0
  75. package/dist/jobs/job-runner-helpers.d.ts +5 -0
  76. package/dist/jobs/job-runner-helpers.js +10 -0
  77. package/dist/jobs/job-runner-types.d.ts +1 -0
  78. package/dist/jobs/job-runner.js +3 -51
  79. package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
  80. package/dist/pipeline/aggregate-type-removal.js +146 -0
  81. package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
  82. package/dist/pipeline/catch-up-job-consumer.js +38 -0
  83. package/dist/pipeline/dispatch-write.js +4 -9
  84. package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
  85. package/dist/pipeline/job-trigger-consumer.js +48 -0
  86. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
  87. package/dist/pipeline/pre-transaction-reservations.js +16 -0
  88. package/dist/pipeline/system-hooks.d.ts +0 -3
  89. package/dist/pipeline/system-hooks.js +0 -48
  90. package/dist/ui-types/index.d.ts +1 -1
  91. package/package.json +11 -3
  92. package/src/changes.json +42 -0
@@ -0,0 +1,37 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
+ import type { DbConnection } from "../db/connection.ts";
3
+ import type { Registry } from "../engine/types/feature.ts";
4
+ export declare class AggregateTypeRemovalError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export type AggregateTypeStats = {
8
+ readonly aggregateType: string;
9
+ readonly events: number;
10
+ readonly aggregates: number;
11
+ readonly tenants: number;
12
+ };
13
+ export type WipedProjection = {
14
+ readonly projection: string;
15
+ readonly table: EntityTableMeta;
16
+ };
17
+ export type AggregateTypeRemovalPlan = {
18
+ readonly stats: readonly AggregateTypeStats[];
19
+ readonly wipedProjections: readonly WipedProjection[];
20
+ };
21
+ export type AggregateTypeRemovalDeleted = {
22
+ readonly events: number;
23
+ readonly snapshots: number;
24
+ readonly archivedStreams: number;
25
+ readonly upcasterDeadLetters: number;
26
+ readonly rebuildDeadLetters: number;
27
+ readonly projectionRows: Readonly<Record<string, number>>;
28
+ };
29
+ export type AggregateTypeRemovalResult = {
30
+ readonly plan: AggregateTypeRemovalPlan;
31
+ readonly deleted?: AggregateTypeRemovalDeleted;
32
+ };
33
+ export type RemoveAggregateTypesOptions = {
34
+ readonly types: readonly string[];
35
+ readonly apply: boolean;
36
+ };
37
+ export declare function removeAggregateTypes(db: DbConnection, registry: Registry, options: RemoveAggregateTypesOptions): Promise<AggregateTypeRemovalResult>;
@@ -0,0 +1,146 @@
1
+ import { lockEventConsumersShareMode } from "../db/queries/event-consumer.js";
2
+ import { aggregateWhere, deleteMany, transaction } from "../db/query.js";
3
+ import { runInSystemRowLevelSecurityScope } from "../db/row-level-security-scope.js";
4
+ import { tableExists } from "../db/schema-inspection.js";
5
+ import { asEntityTableMeta, tableNameOf } from "../db/table-introspection.js";
6
+ import { withExecutorManagedWrites } from "../db/tenant-db.js";
7
+ import { archivedStreamsTable } from "../event-store/archive.js";
8
+ import { eventsTable } from "../event-store/events-schema.js";
9
+ import { rebuildDeadLetterTable } from "../event-store/rebuild-dead-letter.js";
10
+ import { snapshotsTable } from "../event-store/snapshot.js";
11
+ import { upcasterDeadLetterTable } from "../event-store/upcaster-dead-letter.js";
12
+ // Whole-type removal for aggregate types that must not survive in the event log, e.g. old events
13
+ // that no longer replay into their projection (a column became NOT NULL) and block
14
+ // `schema apply`. pruneEvents (event-retention.ts) cannot do this: it is age-bound, events-only
15
+ // and refuses while a consumer lags. Here the lag is ignored on purpose, because the removed
16
+ // types leave the log entirely and no consumer has anything left to deliver for them.
17
+ export class AggregateTypeRemovalError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "AggregateTypeRemovalError";
21
+ }
22
+ }
23
+ async function countDistinct(db, field, aggregateType) {
24
+ const rows = await aggregateWhere(db, eventsTable, { measure: { fn: "countDistinct", field } }, { aggregateType });
25
+ return rows[0]?.value ?? 0;
26
+ }
27
+ async function countEvents(db, aggregateType) {
28
+ const rows = await aggregateWhere(db, eventsTable, { measure: { fn: "count" } }, { aggregateType });
29
+ return rows[0]?.value ?? 0;
30
+ }
31
+ async function statsOf(db, aggregateType) {
32
+ return {
33
+ aggregateType,
34
+ events: await countEvents(db, aggregateType),
35
+ aggregates: await countDistinct(db, "aggregateId", aggregateType),
36
+ tenants: await countDistinct(db, "tenantId", aggregateType),
37
+ };
38
+ }
39
+ // "app-release.created" -> "app-release"; qualified names without a dot carry no aggregate type.
40
+ function aggregateTypeOfEventType(eventType) {
41
+ const dot = eventType.indexOf(".");
42
+ return dot === -1 ? undefined : eventType.slice(0, dot);
43
+ }
44
+ // A projection fed only by removed types would be rebuilt to empty, so its rows are deleted. One
45
+ // that also reads other types would keep rows from the removed ones until a rebuild; guessing
46
+ // which rows belong to which type is not possible (row ids are projection-specific), so the
47
+ // whole run refuses instead of leaving ghost rows that the next rebuild aborts on.
48
+ function classifyFeed(involved, removed) {
49
+ const touched = [...involved].filter((type) => removed.has(type));
50
+ if (touched.length === 0)
51
+ return "untouched";
52
+ return touched.length === involved.size ? "wipe" : "mixed";
53
+ }
54
+ function tableMetaOrThrow(table, owner) {
55
+ const meta = asEntityTableMeta(table);
56
+ if (meta === undefined) {
57
+ throw new AggregateTypeRemovalError(`"${owner}" has a table (${tableNameOf(table)}) without table metadata; cannot remove its rows.`);
58
+ }
59
+ return meta;
60
+ }
61
+ function planProjections(registry, removed) {
62
+ const wiped = [];
63
+ const refused = [];
64
+ const consider = (name, involved, table) => {
65
+ const feed = classifyFeed(involved, removed);
66
+ if (feed === "mixed")
67
+ refused.push(`${name} (${[...involved].sort().join(", ")})`);
68
+ if (feed === "wipe")
69
+ wiped.push({ projection: name, table: tableMetaOrThrow(table, name) });
70
+ };
71
+ for (const [name, def] of registry.getAllProjections()) {
72
+ const involved = new Set([
73
+ ...(Array.isArray(def.source) ? def.source : [def.source]),
74
+ ...(def.extraSources ?? []),
75
+ ]);
76
+ consider(name, involved, def.table);
77
+ }
78
+ for (const [name, def] of registry.getAllMultiStreamProjections()) {
79
+ if (def.table === undefined)
80
+ continue;
81
+ const involved = new Set();
82
+ for (const eventType of Object.keys(def.apply)) {
83
+ // A qualified event name ("feature:event:name") carries no aggregate type; keeping it as its
84
+ // own entry makes a projection that also reads a removed type "mixed" instead of wiping it.
85
+ involved.add(aggregateTypeOfEventType(eventType) ?? eventType);
86
+ }
87
+ consider(name, involved, def.table);
88
+ }
89
+ if (refused.length > 0) {
90
+ throw new AggregateTypeRemovalError(`Refusing: these projections are fed by removed AND other aggregate types, so their rows cannot be ` +
91
+ `split by type: ${refused.join("; ")}. Nothing was deleted.`);
92
+ }
93
+ return wiped;
94
+ }
95
+ async function assertTypesKnown(registry, stats) {
96
+ const unknown = stats
97
+ .filter((s) => s.events === 0 && !registry.getAllEntities().has(s.aggregateType))
98
+ .map((s) => s.aggregateType);
99
+ if (unknown.length > 0) {
100
+ throw new AggregateTypeRemovalError(`Unknown aggregate type(s): ${unknown.join(", ")} (no registered entity and no events). Nothing was deleted.`);
101
+ }
102
+ }
103
+ async function planRemoval(db, registry, types) {
104
+ const stats = [];
105
+ for (const type of types)
106
+ stats.push(await statsOf(db, type));
107
+ await assertTypesKnown(registry, stats);
108
+ return { stats, wipedProjections: planProjections(registry, new Set(types)) };
109
+ }
110
+ async function deleteIfPresent(tx, table, tableName, types) {
111
+ // Dead-letter and archive tables are created lazily, so a fresh database may not have them.
112
+ if (!(await tableExists(tx, `public.${tableName}`)))
113
+ return 0;
114
+ return deleteMany(tx, table, { aggregateType: [...types] });
115
+ }
116
+ async function applyRemoval(tx, plan, types) {
117
+ const projectionRows = {};
118
+ const managed = withExecutorManagedWrites(tx);
119
+ for (const { projection, table } of plan.wipedProjections) {
120
+ projectionRows[projection] = await deleteMany(managed, table, {});
121
+ }
122
+ return {
123
+ events: await deleteMany(tx, eventsTable, { aggregateType: [...types] }),
124
+ snapshots: await deleteIfPresent(tx, snapshotsTable, "kumiko_snapshots", types),
125
+ archivedStreams: await deleteIfPresent(tx, archivedStreamsTable, "kumiko_archived_streams", types),
126
+ upcasterDeadLetters: await deleteIfPresent(tx, upcasterDeadLetterTable, "kumiko_upcaster_dead_letters", types),
127
+ rebuildDeadLetters: await deleteIfPresent(tx, rebuildDeadLetterTable, "kumiko_rebuild_dead_letters", types),
128
+ projectionRows,
129
+ };
130
+ }
131
+ export async function removeAggregateTypes(db, registry, options) {
132
+ if (options.types.length === 0 || options.types.some((type) => type.length === 0)) {
133
+ throw new AggregateTypeRemovalError("At least one non-empty aggregate type is required.");
134
+ }
135
+ return runInSystemRowLevelSecurityScope("aggregate type removal deletes the events and read rows of all tenants", () => transaction(db, async (tx) => {
136
+ if (options.apply) {
137
+ // Same lock as pruneEvents: no consumer may bootstrap or advance its cursor between the
138
+ // plan and the delete.
139
+ await lockEventConsumersShareMode(tx);
140
+ }
141
+ const plan = await planRemoval(tx, registry, options.types);
142
+ if (!options.apply)
143
+ return { plan };
144
+ return { plan, deleted: await applyRemoval(tx, plan, options.types) };
145
+ }));
146
+ }
@@ -0,0 +1,15 @@
1
+ import type { JobDefinition } from "@cosmicdrift/kumiko-types/config";
2
+ import type { JobRunnerRef } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { DbConnection } from "../db/connection.ts";
4
+ import { type EventUpcasters } from "../event-store/upcaster.ts";
5
+ import type { EventConsumer } from "./event-dispatcher.ts";
6
+ export declare const CATCH_UP_JOB_CONSUMER_PREFIX = "job:";
7
+ type EventJobTrigger = Extract<JobDefinition["trigger"], {
8
+ readonly on: unknown;
9
+ }>;
10
+ export declare function catchUpEventTypesOf(trigger: EventJobTrigger): ReadonlySet<string>;
11
+ export declare function createCatchUpJobEventConsumer(jobRunner: JobRunnerRef, jobName: string, featureName: string | undefined, eventTypes: ReadonlySet<string>, upcast: {
12
+ readonly db: DbConnection;
13
+ readonly upcasters: EventUpcasters;
14
+ }): EventConsumer;
15
+ export {};
@@ -0,0 +1,38 @@
1
+ import { upcastStoredEvent } from "../event-store/upcaster.js";
2
+ export const CATCH_UP_JOB_CONSUMER_PREFIX = "job:";
3
+ export function catchUpEventTypesOf(trigger) {
4
+ const entries = Array.isArray(trigger.on) ? trigger.on : [trigger.on];
5
+ return new Set(entries.map((entry) => (typeof entry === "string" ? entry : entry.name)));
6
+ }
7
+ // One consumer per catchUp job: its own cursor starts at the beginning of the
8
+ // event store, so events older than the job are replayed too. The enqueue id is
9
+ // deterministic (job QN + event id), see catchUpJobId. Replayed events can be
10
+ // older than the event's current schema, so they are upcast before the job sees them. Events
11
+ // outside the trigger's types return before the upcast: another event's broken upcaster must
12
+ // not stall this job's cursor.
13
+ export function createCatchUpJobEventConsumer(jobRunner, jobName, featureName, eventTypes, upcast) {
14
+ return {
15
+ name: `${CATCH_UP_JOB_CONSUMER_PREFIX}${jobName}`,
16
+ delivery: "shared",
17
+ startFrom: "beginning",
18
+ ...(featureName !== undefined && { featureName }),
19
+ handler: async (storedEvent) => {
20
+ // skip: not one of this job's trigger events; the runner never matches it anyway
21
+ if (!eventTypes.has(storedEvent.type))
22
+ return;
23
+ const event = await upcastStoredEvent(storedEvent, upcast.upcasters, {
24
+ db: upcast.db,
25
+ tenantId: storedEvent.tenantId,
26
+ });
27
+ const user = {
28
+ id: event.metadata.userId,
29
+ tenantId: event.tenantId,
30
+ roles: [],
31
+ };
32
+ await jobRunner.handleCatchUpEvent?.(jobName, event.type, event.payload, user, {
33
+ id: event.id,
34
+ headers: event.metadata.headers ?? {},
35
+ });
36
+ },
37
+ };
38
+ }
@@ -19,7 +19,7 @@ import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, is
19
19
  import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
20
20
  import { perCommandNestedDenied } from "./handler-context-phase-denials.js";
21
21
  import { runPreHandlerGates } from "./pre-handler-gates.js";
22
- import { consumeReservation, hasPreTransactionPhase, } from "./pre-transaction-reservations.js";
22
+ import { consumeReservation, hasPreTransactionPhase, nestedPreTransactionDenial, } from "./pre-transaction-reservations.js";
23
23
  import { runProjections } from "./projections-runner.js";
24
24
  function getTable(ctx, entityName) {
25
25
  const { registry, tableCache } = ctx;
@@ -390,14 +390,9 @@ async function runWriteGates(ctx, handler, type, payload, user) {
390
390
  if (!gates.isSuccess)
391
391
  return failedStep(gates);
392
392
  const parsedPayload = reservation ? reservation.parsedPayload : gates.payload;
393
- // A handler with a pre-transaction phase cannot run that phase from inside an open transaction;
394
- // running it without would skip the reservation or the prepared data, so a nested ctx.dispatch.write of
395
- // such a handler is rejected instead.
396
- if (needsPreTransactionPhase && !reservation) {
397
- return failedStep(writeFailure(new InternalError({
398
- message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
399
- })));
400
- }
393
+ const nestedDenial = reservation ? undefined : nestedPreTransactionDenial(handler, type);
394
+ if (nestedDenial)
395
+ return failedStep(nestedDenial);
401
396
  return { kind: "run", parsedPayload, reservation };
402
397
  }
403
398
  // Reads via the guard's own db handle — for r.systemScope() handlers
@@ -0,0 +1,5 @@
1
+ import type { JobRunnerRef } from "@cosmicdrift/kumiko-types/handlers";
2
+ import type { Registry } from "../engine/types/index.ts";
3
+ import type { EventConsumer } from "./event-dispatcher.ts";
4
+ export declare const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
5
+ export declare function createJobTriggerEventConsumer(jobRunner: JobRunnerRef, registry: Registry): EventConsumer;
@@ -0,0 +1,48 @@
1
+ // --- Job-Trigger Consumer (async, via event-dispatcher) ---
2
+ //
3
+ // r.job's `trigger.on` historically only fired via the synchronous
4
+ // write-handler dispatch path (dispatch-write.ts's afterCommitHooks calling
5
+ // jobRunner.handleEvent). Events appended any other way — an
6
+ // r.multiStreamProjection's ctx.events.unsafeAppend, or a raw
7
+ // event-store-executor write (e.g. `files`' fileRef.created) — never
8
+ // reached it, so a job could never trigger on an r.defineEvent-registered
9
+ // event.
10
+ //
11
+ // This consumer closes that gap the same way search/SSE do: read every
12
+ // committed event off the shared cursor and re-check job triggers. It is
13
+ // scoped EXACTLY to the gap — the write/query-handler-QN skip below is
14
+ // defense-in-depth: no stored event's `type` is a handler QN today (entity
15
+ // events are "entity.verb"; ctx.events.append enforces defineEvent-only
16
+ // ownership), but if that ever changes, this consumer must not re-fire a
17
+ // trigger the synchronous dispatch-write.ts path already handled.
18
+ //
19
+ // Delivery is at-least-once (cursor semantics) where the synchronous path
20
+ // is effectively once — job handlers reached via an r.defineEvent trigger
21
+ // must be idempotent (same expectation r.multiStreamProjection applies
22
+ // already carry).
23
+ export const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
24
+ export function createJobTriggerEventConsumer(jobRunner, registry) {
25
+ return {
26
+ name: JOB_TRIGGER_CONSUMER_NAME,
27
+ handler: async (event) => {
28
+ // skip: write/query-handler QN — already dispatched synchronously by
29
+ // dispatch-write.ts's afterCommitHooks. Re-firing here would
30
+ // double-enqueue every existing handler-triggered job.
31
+ if (registry.getWriteHandler(event.type) || registry.getQueryHandler(event.type))
32
+ return;
33
+ // skip: no r.defineEvent registered under this type — nothing this
34
+ // consumer is responsible for.
35
+ if (!registry.getEvent(event.type))
36
+ return;
37
+ const user = {
38
+ id: event.metadata.userId,
39
+ tenantId: event.tenantId,
40
+ roles: [],
41
+ };
42
+ await jobRunner.handleEvent(event.type, event.payload, user, {
43
+ id: event.id,
44
+ headers: event.metadata.headers ?? {},
45
+ });
46
+ },
47
+ };
48
+ }
@@ -1,6 +1,6 @@
1
- import type { ReservationHandle, WriteHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
1
+ import type { ReservationHandle, WriteHandlerDef, WriteResult } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import type { SessionUser } from "../engine/types/index.ts";
3
- import type { WriteErrorInfo } from "../errors/index.ts";
3
+ import { type WriteErrorInfo } from "../errors/index.ts";
4
4
  import type { BatchCommand, DispatchContext } from "./dispatch-shared.ts";
5
5
  import type { WriteOrigin } from "./write-origin.ts";
6
6
  type ReservationConfirm = ReservationHandle["confirmInTransaction"];
@@ -10,6 +10,7 @@ export type ConsumedReservation = {
10
10
  readonly parsedPayload: unknown;
11
11
  };
12
12
  export declare function hasPreTransactionPhase(handler: WriteHandlerDef): boolean;
13
+ export declare function nestedPreTransactionDenial(handler: WriteHandlerDef, type: string): WriteResult | undefined;
13
14
  export declare function runAsCommand<T>(reserved: {
14
15
  readonly type: string;
15
16
  readonly reservation: ConsumedReservation;
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { InternalError, writeFailure } from "../errors/index.js";
2
3
  import { toWriteErrorInfo } from "../errors/write-error-info.js";
3
4
  import { createFallbackLogger } from "../logging/utils.js";
4
5
  import { buildHandlerContext, isMemberResolutionPrincipal } from "./dispatch-shared.js";
@@ -8,6 +9,21 @@ import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
8
9
  export function hasPreTransactionPhase(handler) {
9
10
  return handler.reserveBeforeTransaction !== undefined || handler.beforeTransaction !== undefined;
10
11
  }
12
+ // beforeTransactionWhenNested: "skip" lets the handler run nested without the phase and does the work itself.
13
+ // Boot validation rejects the option next to a reservation, so only beforeTransaction is skipped here.
14
+ function runsWithoutPhaseWhenNested(handler) {
15
+ return (handler.beforeTransactionWhenNested === "skip" && handler.reserveBeforeTransaction === undefined);
16
+ }
17
+ // For a run the top-level command did not prepare. A pre-transaction phase cannot run from inside an
18
+ // open transaction, and running without it would skip the reservation or the prepared data, so a
19
+ // nested ctx.dispatch.write of such a handler is rejected instead.
20
+ export function nestedPreTransactionDenial(handler, type) {
21
+ if (!hasPreTransactionPhase(handler) || runsWithoutPhaseWhenNested(handler))
22
+ return undefined;
23
+ return writeFailure(new InternalError({
24
+ message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
25
+ }));
26
+ }
11
27
  // One-shot permission per top-level command: the handler that reserved before the transaction
12
28
  // consumes it on entry. A capped handler reached any other way (nested ctx.dispatch.write, writeAs, a
13
29
  // second execution of the same type) finds it spent and is rejected instead of running unreserved.
@@ -1,4 +1,3 @@
1
- import type { JobRunnerRef } from "@cosmicdrift/kumiko-types/handlers";
2
1
  import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
3
2
  import { type SseBroker } from "../api/sse-broker.ts";
4
3
  import type { Registry, TenantId } from "../engine/types/index.ts";
@@ -21,8 +20,6 @@ export declare function hasErasedSearchableSubjectField(entityName: string, stat
21
20
  export declare function buildSearchDocument(entityName: string, entityId: EntityId, state: Record<string, unknown>, registry: Registry): Promise<SearchDocument | null>;
22
21
  export declare const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
23
22
  export declare function createSseBroadcastEventConsumer(sseBroker: SseBroker): EventConsumer;
24
- export declare const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
25
- export declare function createJobTriggerEventConsumer(jobRunner: JobRunnerRef, registry: Registry): EventConsumer;
26
23
  export declare const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
27
24
  export declare function createAccessInvalidationEventConsumer(sseBroker: SseBroker, opts?: {
28
25
  readonly now?: () => number;
@@ -419,54 +419,6 @@ export function createSseBroadcastEventConsumer(sseBroker) {
419
419
  },
420
420
  };
421
421
  }
422
- // --- Job-Trigger Consumer (async, via event-dispatcher) ---
423
- //
424
- // r.job's `trigger.on` historically only fired via the synchronous
425
- // write-handler dispatch path (dispatch-write.ts's afterCommitHooks calling
426
- // jobRunner.handleEvent). Events appended any other way — an
427
- // r.multiStreamProjection's ctx.events.unsafeAppend, or a raw
428
- // event-store-executor write (e.g. `files`' fileRef.created) — never
429
- // reached it, so a job could never trigger on an r.defineEvent-registered
430
- // event.
431
- //
432
- // This consumer closes that gap the same way search/SSE do: read every
433
- // committed event off the shared cursor and re-check job triggers. It is
434
- // scoped EXACTLY to the gap — the write/query-handler-QN skip below is
435
- // defense-in-depth: no stored event's `type` is a handler QN today (entity
436
- // events are "entity.verb"; ctx.events.append enforces defineEvent-only
437
- // ownership), but if that ever changes, this consumer must not re-fire a
438
- // trigger the synchronous dispatch-write.ts path already handled.
439
- //
440
- // Delivery is at-least-once (cursor semantics) where the synchronous path
441
- // is effectively once — job handlers reached via an r.defineEvent trigger
442
- // must be idempotent (same expectation r.multiStreamProjection applies
443
- // already carry).
444
- export const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
445
- export function createJobTriggerEventConsumer(jobRunner, registry) {
446
- return {
447
- name: JOB_TRIGGER_CONSUMER_NAME,
448
- handler: async (event) => {
449
- // skip: write/query-handler QN — already dispatched synchronously by
450
- // dispatch-write.ts's afterCommitHooks. Re-firing here would
451
- // double-enqueue every existing handler-triggered job.
452
- if (registry.getWriteHandler(event.type) || registry.getQueryHandler(event.type))
453
- return;
454
- // skip: no r.defineEvent registered under this type — nothing this
455
- // consumer is responsible for.
456
- if (!registry.getEvent(event.type))
457
- return;
458
- const user = {
459
- id: event.metadata.userId,
460
- tenantId: event.tenantId,
461
- roles: [],
462
- };
463
- await jobRunner.handleEvent(event.type, event.payload, user, {
464
- id: event.id,
465
- headers: event.metadata.headers ?? {},
466
- });
467
- },
468
- };
469
- }
470
422
  // --- Access-Invalidation Consumer (async, via event-dispatcher) ---
471
423
  //
472
424
  // This consumer is the last leg: it watches for the two event types that
@@ -10,7 +10,7 @@ export type { EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, Options
10
10
  export type { AccessRule } from "../engine/types/handlers.ts";
11
11
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
12
12
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
13
- export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, DashboardChartMarkerKind, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRefresh, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditScreenDefinition, EntityListFacetConfig, EntityListScreenDefinition, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBulkAction, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, ListTableOptions, MetricNavigate, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListEmptyStateSpec, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenSlots, ScreenVisibility, SecretMintConfirmStep, SecretMintScreenDefinition, SecretsEditScreenDefinition, ToolbarAction, } from "../engine/types/screen.ts";
13
+ export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, DashboardChartMarkerKind, DashboardChartPanel, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRefresh, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditScreenDefinition, EntityListFacetConfig, EntityListScreenDefinition, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBulkAction, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, ListTableOptions, MetricNavigate, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListEmptyStateSpec, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFallback, ScreenFallbackByReason, ScreenFallbackReason, ScreenSlots, ScreenVisibility, SecretMintConfirmStep, SecretMintScreenDefinition, SecretsEditScreenDefinition, ToolbarAction, } from "../engine/types/screen.ts";
14
14
  export { MAX_BULK_SELECTION } from "../engine/types/screen.ts";
15
15
  export type { TargetRef } from "../engine/types/target-ref.ts";
16
16
  export type { TreeAction } from "../engine/types/tree-node.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.393.0",
3
+ "version": "0.394.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -160,6 +160,10 @@
160
160
  "types": "./dist/consumer-cli.d.ts",
161
161
  "default": "./dist/consumer-cli.js"
162
162
  },
163
+ "./events-cli": {
164
+ "types": "./dist/events-cli.d.ts",
165
+ "default": "./dist/events-cli.js"
166
+ },
163
167
  "./upgrade-cli": {
164
168
  "types": "./dist/upgrade-cli.d.ts",
165
169
  "default": "./dist/upgrade-cli.js"
@@ -199,7 +203,7 @@
199
203
  "./package.json": "./package.json"
200
204
  },
201
205
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.393.0",
206
+ "@cosmicdrift/kumiko-types": "0.394.0",
203
207
  "bullmq": "^5.76.7",
204
208
  "hono": "^4.13.1",
205
209
  "i18next": "^26.1.0",
@@ -221,7 +225,7 @@
221
225
  }
222
226
  },
223
227
  "devDependencies": {
224
- "@cosmicdrift/kumiko-testing": "0.393.0",
228
+ "@cosmicdrift/kumiko-testing": "0.394.0",
225
229
  "bun-types": "^1.4.4",
226
230
  "fflate": "^0.8.2",
227
231
  "meilisearch": "^0.58.0",
@@ -361,6 +365,10 @@
361
365
  "types": "./dist/consumer-cli.d.ts",
362
366
  "default": "./dist/consumer-cli.js"
363
367
  },
368
+ "./events-cli": {
369
+ "types": "./dist/events-cli.d.ts",
370
+ "default": "./dist/events-cli.js"
371
+ },
364
372
  "./upgrade-cli": {
365
373
  "types": "./dist/upgrade-cli.d.ts",
366
374
  "default": "./dist/upgrade-cli.js"
package/src/changes.json CHANGED
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.394.0",
4
+ "type": "fix",
5
+ "title": "AGENTS.md points app tests at kumiko-dev-server/testing",
6
+ "detail": "The Tests section told agents to import setupAppTestStack and seedTenant from @cosmicdrift/kumiko-testing, which does not export them; an agent that followed it got a missing-export error and had to copy the import from the scaffolded test. The section now names @cosmicdrift/kumiko-dev-server/testing. No code change."
7
+ },
8
+ {
9
+ "version": "0.394.0",
10
+ "type": "fix",
11
+ "title": "compliance profile labels are English",
12
+ "detail": "The labels of the built-in compliance profiles (eu-dsgvo, swiss-dsg, de-hr-dsgvo-hgb, minimal) were German in the English source; locale packages keep their option translations, and the compliance-profiles catalog now shows those translations instead of the raw source label. Authority names such as the de-hr authority contact stay as the authority calls itself."
13
+ },
14
+ {
15
+ "version": "0.394.0",
16
+ "type": "improvement",
17
+ "title": "Guides and screenshots for system check, inbound mail, billing and the 0.392 upgrade",
18
+ "detail": "New guides system-check, inbound-mail, billing-plans-and-checkout and upgrading-to-0-392 with screenshots from the use-all-bundled and styleguide specs. security-defaults splits the row level security section and adds a scope diagram, a raw access example and crossTenantReads; handler-context-and-embedded-fields gets a namespace table; auth-external-login, prod-bootstrap, screen-layout, renderer-widget-options, rbac-tenant-isolation and cap-overview cover returnTo, static login, MFA policies, requiredEnvFor, jobs readRoles, header actions, screen gates, previewOpenByDefault, bulk actions, export and showLimit. New user guide operator-screens, and the sample index wires matrix screenshots into the feature reference previews of admin-shell, audit, jobs, cap-overview, tenant-settings, delivery, compliance-profiles, account-security and sessions. No framework code change."
19
+ },
20
+ {
21
+ "version": "0.394.0",
22
+ "type": "improvement",
23
+ "title": "events prune removes retired aggregate types from the event log",
24
+ "detail": "`@cosmicdrift/kumiko-framework/events-cli` exports `runEventsCli(argv, out, { features, rowLevelSecurity? })`. `events prune --aggregate-type <type> [--aggregate-type <type> ...]` reports events, aggregates and tenants per type; with `--apply` it deletes, in one transaction and in system scope, the events, snapshots, archive markers and dead letters of these types and empties the projections fed only by them. A projection that also reads other types makes the run refuse. Unknown or empty types fail without deleting anything. Use it when old events of a retired type break the rebuild of `schema apply`. Apps add `if (cmd === \"events\")` to `bin/kumiko.ts`; new scaffolds get it."
25
+ },
26
+ {
27
+ "version": "0.394.0",
28
+ "type": "improvement",
29
+ "title": "beforeTransactionWhenNested skip lets a handler with a beforeTransaction phase run nested",
30
+ "detail": "`beforeTransactionWhenNested: \"skip\"` on a write handler lets a nested ctx.dispatch.write run it without the phase. `event.prepared` is then `undefined` (typed `TPrepared | undefined`) and the handler does that work inside its own transaction. Top-level commands still run the phase. Without the option a nested call stays rejected. Boot rejects the option without beforeTransaction and together with reserveBeforeTransaction; transaction \"perCommand\" is already rejected next to beforeTransaction."
31
+ },
32
+ {
33
+ "version": "0.394.0",
34
+ "type": "improvement",
35
+ "title": "KMS outages surface as EncryptionUnavailableError",
36
+ "detail": "The PII key store configured with configurePiiSubjectKms wraps adapter failures (database, Key Manager, network) in EncryptionUnavailableError with code encryption_unavailable and HTTP status 503, exported from @cosmicdrift/kumiko-framework/crypto together with ENCRYPTION_UNAVAILABLE_CODE. Key-state errors (KeyNotFoundError, KeyErasedError, KeyAlreadyExistsError) keep their types. Callers can tell an outage, which they retry later, from a problem with the record."
37
+ },
38
+ {
39
+ "version": "0.394.0",
40
+ "type": "fix",
41
+ "title": "Live signals for entity names with uppercase runs reach the client",
42
+ "detail": "`toKebab` now lives once in `@cosmicdrift/kumiko-types/kebab-case` and is used by the server read-access keys, the qualified-name helpers and the renderer. Before, the renderer and server copies could diverge for names like `aiURLCheck`, so a screen subscribed to a name the server never signalled. Boot now fails when two entities of different features fold to the same key (`billing.period` and `billingPeriod`), instead of merging their read rules."
43
+ },
2
44
  {
3
45
  "version": "0.392.0",
4
46
  "type": "improvement",