@cosmicdrift/kumiko-framework 0.146.4 → 0.147.1

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 (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -0,0 +1,264 @@
1
+ import { requestContext } from "../api/request-context";
2
+ import type { DbConnection, DbTx } from "../db/connection";
3
+ import {
4
+ insertConsumerIfAbsent,
5
+ markConsumerProcessing,
6
+ selectConsumerForUpdateSkipLocked,
7
+ updateConsumerDeliveryOutcome,
8
+ } from "../db/queries/event-consumer";
9
+ import { selectEventsHeadId } from "../db/queries/event-store";
10
+ import { coerceRow, extractTableInfo, selectMany } from "../db/query";
11
+ import type { AppContext } from "../engine/types";
12
+ import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
13
+ import {
14
+ emitDispatcherError,
15
+ emitEventConsumerLag,
16
+ getFallbackMeter,
17
+ type Meter,
18
+ } from "../observability";
19
+ import {
20
+ ConsumerStatuses,
21
+ eventConsumerStateTable,
22
+ SHARED_INSTANCE_SENTINEL,
23
+ } from "./event-consumer-state";
24
+ import type { EventConsumer } from "./event-dispatcher";
25
+
26
+ // Per-consumer pass mechanics: acquire the state row, fetch pending events,
27
+ // hand them to the consumer's handler in order, persist the outcome. Split
28
+ // out of event-dispatcher.ts so the delivery loop is independently readable
29
+ // from the public lifecycle surface (start/stop/runOnce) and the ops
30
+ // recovery surface (event-dispatcher-admin.ts).
31
+ //
32
+ // Free functions (not closures) — every helper takes an explicit `tx`, none
33
+ // use the outer dispatcher's closure state.
34
+
35
+ export type ConsumerStateRowShape = {
36
+ readonly name: string;
37
+ readonly instanceId: string;
38
+ readonly lastProcessedEventId: bigint;
39
+ readonly status: string;
40
+ readonly attempts: number;
41
+ readonly lastError: string | null;
42
+ readonly updatedAt: Temporal.Instant;
43
+ };
44
+ export type ConsumerStateRow = ConsumerStateRowShape;
45
+
46
+ type StoredEventRow = {
47
+ readonly id: bigint;
48
+ readonly aggregateId: string;
49
+ readonly aggregateType: string;
50
+ readonly tenantId: string;
51
+ readonly version: number;
52
+ readonly type: string;
53
+ readonly eventVersion: number;
54
+ readonly payload: Record<string, unknown>;
55
+ readonly metadata: import("../event-store/event-store").EventMetadata;
56
+ readonly createdAt: Temporal.Instant;
57
+ readonly createdBy: string;
58
+ };
59
+
60
+ export type AcquireOutcome =
61
+ | { readonly state: ConsumerStateRow; readonly skip: null }
62
+ | {
63
+ readonly state: null;
64
+ readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
65
+ };
66
+
67
+ // Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
68
+ // The row must exist — start() pre-registers every consumer up front so
69
+ // prune (event-retention) sees their cursors as soon as the process is up,
70
+ // closing the race where a lazy-bootstrapped consumer's cursor is absent
71
+ // during prune and its events are silently deleted.
72
+ //
73
+ // skip="not_registered" signals a row-missing-despite-start condition.
74
+ // Production shouldn't hit this — it means either start() wasn't called
75
+ // (runOnce() guards against that) or the state row was deleted externally
76
+ // (a test TRUNCATE without subsequent ensureRegistered(), or an operator
77
+ // intervention). Skipping quietly preserves the dispatcher's other
78
+ // consumers and surfaces the issue via the metrics pass-outcome.
79
+ export async function acquireConsumerState(
80
+ tx: DbTx,
81
+ name: string,
82
+ instanceId: string,
83
+ ): Promise<AcquireOutcome> {
84
+ const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
85
+
86
+ if (!rawState) {
87
+ return { state: null, skip: "not_registered" };
88
+ }
89
+
90
+ const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
91
+
92
+ if (!state) {
93
+ // Either the row never existed (no pre-reg, no ensureRegistered) or
94
+ // another instance currently holds the lock with SKIP LOCKED filtering
95
+ // us out. We can't distinguish here in a single query, so return
96
+ // "not_registered" — ops sees a skip-reason instead of silent delivery
97
+ // loss. Under normal operation (start() called, no external tampering)
98
+ // this path is never taken.
99
+ return { state: null, skip: "not_registered" };
100
+ }
101
+
102
+ if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
103
+ if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
104
+ return { state, skip: null };
105
+ }
106
+
107
+ // Shared pre-registration: one row per (consumer, shard), cursor = 0,
108
+ // status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
109
+ // per-instance consumers use the dispatcher's instanceId. Idempotent
110
+ // under restart and concurrent start-calls via ON CONFLICT DO NOTHING
111
+ // on the composite PK — never clobbers an existing cursor.
112
+ export async function preRegisterConsumers(
113
+ db: DbConnection,
114
+ consumers: readonly EventConsumer[],
115
+ dispatcherInstanceId: string | undefined,
116
+ ): Promise<void> {
117
+ for (const consumer of consumers) {
118
+ const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
119
+ await insertConsumerIfAbsent(db, consumer.name, instanceId);
120
+ }
121
+ }
122
+
123
+ // Resolve the instance_id column value for one consumer on this dispatcher.
124
+ // Shared stays at the sentinel; per-instance rides the dispatcher's id.
125
+ // Throws when a per-instance consumer is registered without an instanceId
126
+ // — missing at boot is the sharp-edge to catch, not at first delivery.
127
+ export function consumerInstanceId(
128
+ consumer: EventConsumer,
129
+ dispatcherInstanceId: string | undefined,
130
+ ): string {
131
+ if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
132
+ if (!dispatcherInstanceId) {
133
+ throw new Error(
134
+ `EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
135
+ `pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
136
+ );
137
+ }
138
+ return dispatcherInstanceId;
139
+ }
140
+
141
+ // Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
142
+ // lock already guarantees single-writer semantics; this is purely
143
+ // informational (and resets on commit to idle/dead via persistConsumerOutcome).
144
+ export async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
145
+ await markConsumerProcessing(tx, name, instanceId);
146
+ }
147
+
148
+ export async function fetchPendingEvents(
149
+ tx: DbTx,
150
+ cursor: bigint,
151
+ batchSize: number,
152
+ ): Promise<ReadonlyArray<StoredEventRow>> {
153
+ return (await selectMany(
154
+ tx,
155
+ eventsTable,
156
+ { id: { gt: cursor } },
157
+ { orderBy: { col: "id", direction: "asc" }, limit: batchSize },
158
+ )) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
159
+ }
160
+
161
+ export type DeliveryOutcome = {
162
+ readonly cursor: bigint;
163
+ readonly attempts: number;
164
+ readonly lastError: string | null;
165
+ readonly deadLettered: boolean;
166
+ readonly processed: number;
167
+ readonly failed: number;
168
+ };
169
+
170
+ // Deliver events to the consumer's handler in events.id order. Halt-on-
171
+ // poison: a throw breaks the loop, the cursor stays at the last successful
172
+ // event, and attempts climb. At maxAttempts the caller persists status=
173
+ // "dead" and the consumer is parked until ops intervenes (see
174
+ // restartConsumer / skipPoisonEvent).
175
+ export async function deliverEvents(
176
+ consumer: EventConsumer,
177
+ events: ReadonlyArray<StoredEventRow>,
178
+ context: AppContext,
179
+ maxAttempts: number,
180
+ state: ConsumerStateRow,
181
+ ): Promise<DeliveryOutcome> {
182
+ let cursor = state.lastProcessedEventId;
183
+ let attempts = state.attempts;
184
+ let lastError: string | null = state.lastError ?? null;
185
+ let deadLettered = false;
186
+ let processed = 0;
187
+ let failed = 0;
188
+
189
+ for (const row of events) {
190
+ try {
191
+ // Propagate causation: if the handler calls ctx.appendEvent, the new
192
+ // event should record THIS event as its cause. correlationId is
193
+ // inherited unchanged — it survives the hop across streams by design.
194
+ // requestId falls back to a fresh id because the dispatcher runs
195
+ // outside any HTTP request (background poll), and a stable log-
196
+ // correlation handle is still useful for debugging.
197
+ const stored = rowToStoredEvent(row);
198
+ const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
199
+ const causationId = String(stored.id);
200
+ const requestId = requestContext.generateId();
201
+ await requestContext.run({ requestId, correlationId, causationId }, async () => {
202
+ await consumer.handler(stored, context);
203
+ });
204
+ cursor = row.id;
205
+ attempts = 0;
206
+ lastError = null;
207
+ processed += 1;
208
+ } catch (e) {
209
+ const errMessage = e instanceof Error ? e.message : String(e);
210
+ if (consumer.errorPolicy?.skipApplyErrors) {
211
+ // Best-effort mode: record the error on the skip counter so ops
212
+ // can alert on a spike of skipped events, advance the cursor past
213
+ // the bad event, keep going. The consumer stays "idle", not "dead".
214
+ // Also emit a warn-level log line — the metric tells ops THAT events
215
+ // are being dropped, the log tells them WHICH events. Without this
216
+ // a poisoned-then-skipped event is invisible to forensic search.
217
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
218
+ emitDispatcherError(context.meter ?? getFallbackMeter(), {
219
+ handler: consumer.name,
220
+ errorClass,
221
+ });
222
+ context.log?.warn(
223
+ `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
224
+ );
225
+ cursor = row.id;
226
+ attempts = 0;
227
+ lastError = null;
228
+ failed += 1;
229
+ continue;
230
+ }
231
+ attempts += 1;
232
+ lastError = errMessage;
233
+ failed += 1;
234
+ if (attempts >= maxAttempts) deadLettered = true;
235
+ break;
236
+ }
237
+ }
238
+
239
+ return { cursor, attempts, lastError, deadLettered, processed, failed };
240
+ }
241
+
242
+ export async function persistConsumerOutcome(
243
+ tx: DbTx,
244
+ name: string,
245
+ instanceId: string,
246
+ outcome: DeliveryOutcome,
247
+ ): Promise<void> {
248
+ await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
249
+ }
250
+
251
+ // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
252
+ // consistent with the cursor we just advanced to. `MAX(id)` on the events
253
+ // table is an O(1) reverse-index scan — cheap even under load.
254
+ export async function emitLagFromTx(
255
+ tx: DbTx,
256
+ consumerName: string,
257
+ instanceId: string,
258
+ cursor: bigint,
259
+ meter: Meter,
260
+ ): Promise<void> {
261
+ const head = await selectEventsHeadId(tx);
262
+ const lag = head > cursor ? Number(head - cursor) : 0;
263
+ emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
264
+ }