@cosmicdrift/kumiko-framework 0.184.0 → 0.186.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.
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { toMinorUnits } from "../db/money";
2
3
  import { isValidIanaTimeZone } from "../time";
3
4
  import { assertUnreachable } from "../utils";
5
+ import { withDerivedCells } from "./embedded-derived";
4
6
  import type { EmbeddedSubFieldDef, EntityDefinition, FieldDefinition } from "./types";
5
7
  import { DEFAULT_CURRENCIES } from "./types";
6
8
 
@@ -55,6 +57,10 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
55
57
  // row. The safe-integer cap mirrors bigInt mode:"number" — jsonb has no
56
58
  // BIGINT column behind it, so 2^53 is the real representability boundary.
57
59
  return z.number().int().safe();
60
+ case "timestamp":
61
+ // No locatedBy/min/max on EmbeddedSubFieldDef (unlike the top-level
62
+ // timestamp field) — plain UTC-instant ISO-datetime validation.
63
+ return z.iso.datetime();
58
64
  case "decimal": {
59
65
  // No numeric column behind jsonb, so the bounds come from float
60
66
  // representability alone: the value scaled by 10^scale must be a safe
@@ -68,6 +74,13 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
68
74
  message: `at most ${subField.scale} decimal places`,
69
75
  });
70
76
  }
77
+ case "select": {
78
+ const [first, ...rest] = subField.options;
79
+ if (!first) return z.string();
80
+ return z.enum([first, ...rest]);
81
+ }
82
+ case "reference":
83
+ return z.uuid();
71
84
  default:
72
85
  assertUnreachable(subField, "embedded sub-field type");
73
86
  }
@@ -191,13 +204,24 @@ export function fieldToZod(
191
204
  const zodSub = embeddedSubFieldToZod(subField);
192
205
  shape[subName] = subField.required ? zodSub : zodSub.optional();
193
206
  }
194
- const row = z.object(shape);
207
+ const baseRow = z.object(shape);
208
+ const derived = field.derived;
209
+ // The server is the authority for derived cells: a row is recomputed
210
+ // here, overwriting whatever the client sent, instead of merely
211
+ // being checked against it.
212
+ const row =
213
+ derived === undefined
214
+ ? baseRow
215
+ : z.preprocess((value) => withDerivedCells(value, derived), baseRow);
195
216
  if (field.multiple !== true) return row;
196
217
  // `required: true` means non-empty, same reading as multiSelect —
197
218
  // whether the key may be omitted at all is decided by buildInsertSchema
198
- // off the same flag.
199
- const list = z.array(row);
200
- return field.required === true ? list.min(1) : list;
219
+ // off the same flag. `minItems` overrides that default when set.
220
+ let list = z.array(row);
221
+ const min = field.minItems ?? (field.required === true ? 1 : undefined);
222
+ if (min !== undefined) list = list.min(min);
223
+ if (field.maxItems !== undefined) list = list.max(field.maxItems);
224
+ return list;
201
225
  }
202
226
  case "jsonb": {
203
227
  // Free-form jsonb — keys sind tenant-/runtime-defined. Validation
@@ -266,6 +290,57 @@ export function fieldToZod(
266
290
  }
267
291
  }
268
292
 
293
+ // Cross-field check for `EmbeddedFieldDef.totalsMatch`: the sum of a list
294
+ // subfield across every row must equal a sibling top-level money field.
295
+ // Runs via the same z.object().safeParse() call on both the client
296
+ // (form-controller's runValidate) and the server (write handler) — one
297
+ // mechanism, no separate client/server validation path to keep in sync.
298
+ // ponytail: compares raw minor-unit amounts only, not currencies — a row
299
+ // sum in the entity's default currency against a sibling amount tagged with
300
+ // a different currency string still passes. Add a currency-equality check
301
+ // here if multi-currency siblings become a real case.
302
+ function applyTotalsMatchRefinements(
303
+ entity: EntityDefinition,
304
+ schema: z.ZodObject<Record<string, z.ZodTypeAny>>,
305
+ ): z.ZodObject<Record<string, z.ZodTypeAny>> {
306
+ let result = schema;
307
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
308
+ if (field.type !== "embedded" || field.totalsMatch === undefined) continue;
309
+ const totalsMatch = field.totalsMatch;
310
+ result = result.superRefine((values, ctx) => {
311
+ for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
312
+ const rows = values[fieldName] as ReadonlyArray<Record<string, unknown>> | undefined;
313
+ const siblingRaw = values[siblingFieldName];
314
+ // Not sent -> not checkable, not an error (partial update payloads).
315
+ if (rows === undefined || siblingRaw === undefined) continue;
316
+ const siblingAmount =
317
+ typeof siblingRaw === "object" &&
318
+ siblingRaw !== null &&
319
+ "amount" in siblingRaw &&
320
+ typeof (siblingRaw as { amount: unknown }).amount === "number"
321
+ ? (siblingRaw as { amount: number }).amount
322
+ : typeof siblingRaw === "number"
323
+ ? siblingRaw
324
+ : undefined;
325
+ if (siblingAmount === undefined) continue;
326
+ const sumMinor = rows.reduce(
327
+ (total, row) =>
328
+ total + (typeof row[subFieldName] === "number" ? (row[subFieldName] as number) : 0),
329
+ 0,
330
+ );
331
+ if (sumMinor !== toMinorUnits(siblingAmount)) {
332
+ ctx.addIssue({
333
+ code: "custom",
334
+ path: [fieldName],
335
+ message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${toMinorUnits(siblingAmount)})`,
336
+ });
337
+ }
338
+ }
339
+ });
340
+ }
341
+ return result;
342
+ }
343
+
269
344
  export function buildInsertSchema(
270
345
  entity: EntityDefinition,
271
346
  currencies: readonly string[] = [...DEFAULT_CURRENCIES],
@@ -279,7 +354,7 @@ export function buildInsertSchema(
279
354
  shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
280
355
  }
281
356
 
282
- return z.object(shape);
357
+ return applyTotalsMatchRefinements(entity, z.object(shape));
283
358
  }
284
359
 
285
360
  export function buildUpdateSchema(
@@ -300,5 +375,5 @@ export function buildUpdateSchema(
300
375
  shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
301
376
  }
302
377
 
303
- return z.object(shape);
378
+ return applyTotalsMatchRefinements(entity, z.object(shape));
304
379
  }
@@ -96,6 +96,7 @@ export type {
96
96
  DerivedFieldDef,
97
97
  DerivedFieldsMap,
98
98
  DerivedValueType,
99
+ EmbeddedDerivedCellDef,
99
100
  EmbeddedFieldDef,
100
101
  EmbeddedSubFieldDef,
101
102
  EntityDefinition,
@@ -557,3 +557,83 @@ describe("Observability (integration) — error path", () => {
557
557
  expect(errorCounter?.labels?.["handler"]).toBe("err:write:boom");
558
558
  });
559
559
  });
560
+
561
+ // Simulates shared/library code called from several consumer features
562
+ // (framework#1844's ai-foundation scenario) — the same call site, not a
563
+ // copy-pasted inc() per feature. Feature names use real kebab-case (as
564
+ // declared at defineFeature time and used in QN dispatch types) — buildMetricName
565
+ // normalizes "-" to "_" so registration and lookup resolve to the same name.
566
+ function recordSharedCall(ctx: {
567
+ metricsFor: (featureName: string) => { inc: (n: string) => void };
568
+ }) {
569
+ ctx.metricsFor("shared-lib").inc("call_total");
570
+ }
571
+
572
+ const sharedLibFeature = defineFeature("shared-lib", (r) => {
573
+ r.metric("call_total", { type: "counter" });
574
+ });
575
+
576
+ const consumerAFeature = defineFeature("consumer-a", (r) => {
577
+ r.writeHandler(
578
+ "run",
579
+ z.object({}),
580
+ async (_event, ctx) => {
581
+ recordSharedCall(ctx);
582
+ return { isSuccess: true, data: { ok: true } };
583
+ },
584
+ { access: { openToAll: true } },
585
+ );
586
+ });
587
+
588
+ const consumerBFeature = defineFeature("consumer-b", (r) => {
589
+ r.writeHandler(
590
+ "run",
591
+ z.object({}),
592
+ async (_event, ctx) => {
593
+ recordSharedCall(ctx);
594
+ ctx.metricsFor("unregistered-lib").inc("never_total");
595
+ return { isSuccess: true, data: { ok: true } };
596
+ },
597
+ { access: { openToAll: true } },
598
+ );
599
+ });
600
+
601
+ describe("Observability (integration) — ctx.metricsFor", () => {
602
+ let stack: TestStack;
603
+ let provider: RecordingProvider;
604
+
605
+ beforeEach(async () => {
606
+ provider = createRecordingProvider();
607
+ stack = await setupTestStack({
608
+ features: [sharedLibFeature, consumerAFeature, consumerBFeature],
609
+ observability: provider,
610
+ });
611
+ });
612
+
613
+ afterEach(async () => {
614
+ await stack.cleanup();
615
+ });
616
+
617
+ it("resolves the same library-owned metric name from two different consumer features", async () => {
618
+ await stack.http.command("consumer-a:write:run", {}, adminUser);
619
+ await stack.http.command("consumer-b:write:run", {}, adminUser);
620
+
621
+ const sharedEvents = provider.metricEvents.filter(
622
+ (e) => e.type === "counter.inc" && e.name === "kumiko_shared_lib_call_total",
623
+ );
624
+ expect(sharedEvents).toHaveLength(2);
625
+
626
+ const splinteredEvents = provider.metricEvents.filter(
627
+ (e) => e.type === "counter.inc" && /^kumiko_consumer_(a|b)_call_total$/.test(e.name),
628
+ );
629
+ expect(splinteredEvents).toHaveLength(0);
630
+ });
631
+
632
+ it("does not throw and emits nothing for an unregistered metricsFor name", async () => {
633
+ const res = await stack.http.command("consumer-b:write:run", {}, adminUser);
634
+ expect(res.status).toBeLessThan(300);
635
+
636
+ const neverEvents = provider.metricEvents.filter((e) => e.name.includes("never_total"));
637
+ expect(neverEvents).toHaveLength(0);
638
+ });
639
+ });
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  createMetricsHandle,
15
15
  createNoopMetricsHandle,
16
+ createSafeMetricsHandle,
16
17
  createUnboundMetricsHandle,
17
18
  } from "./metrics-handle";
18
19
  export { createNoopProvider } from "./noop-provider";
@@ -66,11 +66,22 @@ export function validateMetricName(name: string, type: MetricType): void {
66
66
 
67
67
  // Prefix a short feature-local metric name with the Kumiko + feature prefix.
68
68
  // Short name: "created_total". Feature: "orders". Result: "kumiko_orders_created_total".
69
+ //
70
+ // Feature names are kebab-case everywhere else (qualified-name segments,
71
+ // r.metric() is called with `feature.name` as registered at defineFeature
72
+ // time) — normalize "-" to "_" here so a feature like "ai-foundation"
73
+ // resolves to the same "kumiko_ai_foundation_x" on both the registration
74
+ // path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
75
+ // instead of the kebab form being rejected outright (framework#1844).
69
76
  export function buildMetricName(featureName: string, shortName: string): string {
70
- if (!SNAKE_CASE.test(featureName)) {
71
- throw new Error(`[Kumiko Observability] Feature name "${featureName}" must be snake_case.`);
77
+ const normalizedFeatureName = featureName.replace(/-/g, "_");
78
+ if (!SNAKE_CASE.test(normalizedFeatureName)) {
79
+ throw new Error(
80
+ `[Kumiko Observability] Feature name "${featureName}" must be kebab-case or snake_case ` +
81
+ `(a-z, 0-9, "-" or "_").`,
82
+ );
72
83
  }
73
- return `kumiko_${featureName}_${shortName}`;
84
+ return `kumiko_${normalizedFeatureName}_${shortName}`;
74
85
  }
75
86
 
76
87
  // Validate label keys: snake_case, not reserved.
@@ -27,6 +27,43 @@ export function createMetricsHandle(meter: Meter, featureName: string): MetricsH
27
27
  };
28
28
  }
29
29
 
30
+ // Same feature-bound resolution as createMetricsHandle, but for an
31
+ // explicit `featureName` chosen by the caller rather than the dispatching
32
+ // handler's own feature (framework#1844). Meant for shared/library code
33
+ // invoked from many features' HandlerContext (ctx.metricsFor) — the
34
+ // library owns one stable metric name instead of splintering into
35
+ // kumiko_<caller>_x per consumer.
36
+ //
37
+ // Decision (framework#1844 DoD): unlike createMetricsHandle, an
38
+ // unregistered name here is a silent no-op, not a throw. This handle is
39
+ // meant for error/catch-path counters in shared code — a missing
40
+ // registration (consuming feature not mounted, metric not declared yet)
41
+ // must not turn an already-swallowed error into a thrown one. Every other
42
+ // failure (invalid featureName, wrong metric type for the call) still
43
+ // throws — only the "not registered" case is swallowed.
44
+ export function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle {
45
+ return {
46
+ inc(shortName, labels, value) {
47
+ const name = buildMetricName(featureName, shortName);
48
+ // skip: unregistered name is the documented no-op contract of this handle
49
+ if (!meter.definitions().has(name)) return;
50
+ meter.counter(name).inc(value, labels);
51
+ },
52
+ observe(shortName, value, labels) {
53
+ const name = buildMetricName(featureName, shortName);
54
+ // skip: unregistered name is the documented no-op contract of this handle
55
+ if (!meter.definitions().has(name)) return;
56
+ meter.histogram(name).observe(value, labels);
57
+ },
58
+ set(shortName, value, labels) {
59
+ const name = buildMetricName(featureName, shortName);
60
+ // skip: unregistered name is the documented no-op contract of this handle
61
+ if (!meter.definitions().has(name)) return;
62
+ meter.gauge(name).set(value, labels);
63
+ },
64
+ };
65
+ }
66
+
30
67
  // Fallback for contexts where the feature is unknown (e.g. system-hooks,
31
68
  // internal pipeline code). Short names are used verbatim — useful for
32
69
  // framework-level usage, but rejected by the Meter unless pre-registered.
@@ -116,6 +116,25 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
116
116
  { access: { roles: ["Admin"] } },
117
117
  );
118
118
 
119
+ // Two inserts, one via ctx.db (tx-bound) and one via ctx.dbOutsideTransaction
120
+ // (unbound pool), then an unconditional failure. Proves the outside-tx write
121
+ // survives the handler's own rollback while the tx-bound one doesn't.
122
+ r.writeHandler(
123
+ "bag:create-outside-tx-then-fail",
124
+ z.object({ label: z.string() }),
125
+ async (event, ctx) => {
126
+ const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
127
+ await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
128
+ await crud.create(
129
+ { label: `${event.payload.label}-outside-tx` },
130
+ event.user,
131
+ ctx.dbOutsideTransaction,
132
+ );
133
+ return writeFailure(new UnprocessableError("intentional_failure"));
134
+ },
135
+ { access: { roles: ["Admin"] } },
136
+ );
137
+
119
138
  // Handler that fetches the secret via ctx.queryAs(system) — proves the
120
139
  // privileged call returns the token field even though the caller (Admin)
121
140
  // couldn't read it themselves.
@@ -233,3 +252,18 @@ describe("ctx.writeAs shares the outer transaction", () => {
233
252
  expect(afterCommitLog).toEqual([]);
234
253
  });
235
254
  });
255
+
256
+ describe("ctx.dbOutsideTransaction", () => {
257
+ test("a write through it survives the handler's own transaction rolling back", async () => {
258
+ const res = await stack.http.write(
259
+ "ctxbridge:write:bag:create-outside-tx-then-fail",
260
+ { label: "probe" },
261
+ admin,
262
+ );
263
+ expect((await res.json()).isSuccess).toBe(false);
264
+
265
+ const bags = await selectMany(stack.db, bagTable);
266
+ const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
267
+ expect(labels).toEqual(["probe-outside-tx"]);
268
+ });
269
+ });
@@ -53,6 +53,7 @@ import { createFileContext } from "../files/file-handle";
53
53
  import {
54
54
  createMetricsHandle,
55
55
  createNoopMetricsHandle,
56
+ createSafeMetricsHandle,
56
57
  emitDispatcherError,
57
58
  emitDispatcherHandler,
58
59
  type getFallbackMeter,
@@ -168,20 +169,24 @@ export async function buildHandlerContext(
168
169
  // but at this point we're the root of the pipeline — cast is safe.
169
170
  const dbSource = resolveDbSource(ctx, tx);
170
171
  const reqCtx = requestContext.get();
171
- const db = dbSource
172
- ? createTenantDb(
173
- dbSource,
174
- user.tenantId,
175
- isSystem ? "system" : "tenant",
176
- context.tracer,
177
- context.meter,
178
- // Propagate the request's AbortSignal so every TenantDb query
179
- // throws when the client has disconnected — handlers with many
180
- // sequential queries skip the rest of the chain instead of
181
- // burning DB-CPU for results no one reads.
182
- reqCtx?.signal,
183
- )
184
- : undefined;
172
+ const buildTenantScopedDb = (source: DbConnection | DbTx) =>
173
+ createTenantDb(
174
+ source,
175
+ user.tenantId,
176
+ isSystem ? "system" : "tenant",
177
+ context.tracer,
178
+ context.meter,
179
+ // Propagate the request's AbortSignal so every TenantDb query
180
+ // throws when the client has disconnected — handlers with many
181
+ // sequential queries skip the rest of the chain instead of
182
+ // burning DB-CPU for results no one reads.
183
+ reqCtx?.signal,
184
+ );
185
+ const db = dbSource ? buildTenantScopedDb(dbSource) : undefined;
186
+ // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
187
+ // through it survive a rollback of the handler's own transaction.
188
+ const outsideTxSource = resolveDbSource(ctx, undefined);
189
+ const dbOutsideTransaction = outsideTxSource ? buildTenantScopedDb(outsideTxSource) : undefined;
185
190
  const log = context.log?.child({
186
191
  handler: type,
187
192
  tenantId: user.tenantId,
@@ -213,6 +218,13 @@ export async function buildHandlerContext(
213
218
  const featureName = registry.getHandlerFeature(type);
214
219
  const metrics =
215
220
  meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
221
+ // ctx.metricsFor(featureName) — shared/library code binds to a feature
222
+ // name of its own choosing instead of the dispatching handler's
223
+ // (framework#1844). Unregistered names no-op rather than throw, see
224
+ // createSafeMetricsHandle.
225
+ const metricsFor = meter
226
+ ? (targetFeatureName: string) => createSafeMetricsHandle(meter, targetFeatureName)
227
+ : () => createNoopMetricsHandle();
216
228
 
217
229
  // Cross-feature bridge. Queries and writes invoked through ctx.* share:
218
230
  // - the current transaction (tx) — nested writes roll back with the parent
@@ -548,6 +560,7 @@ export async function buildHandlerContext(
548
560
  ...context,
549
561
  registry,
550
562
  db,
563
+ dbOutsideTransaction,
551
564
  log,
552
565
  notify,
553
566
  ...(config && { config }),
@@ -566,6 +579,7 @@ export async function buildHandlerContext(
566
579
  }),
567
580
  tracer,
568
581
  metrics,
582
+ metricsFor,
569
583
  tz,
570
584
  // Cancellation signal flows from the HTTP middleware via
571
585
  // requestContext. Conditional spread so non-HTTP entry-points
@@ -58,6 +58,7 @@ export function bridgeStub(opts?: {
58
58
  | "resolveAuthClaims"
59
59
  | "hasFeature"
60
60
  | "metrics"
61
+ | "metricsFor"
61
62
  | "tracer"
62
63
  | "tz"
63
64
  | "user"
@@ -119,6 +120,7 @@ export function bridgeStub(opts?: {
119
120
  // when no effectiveFeatures resolver is wired (tests without toggles).
120
121
  hasFeature: async () => true,
121
122
  metrics: createNoopMetricsHandle(),
123
+ metricsFor: () => createNoopMetricsHandle(),
122
124
  tracer: noopTracer,
123
125
  // Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
124
126
  // ohne dass es ein "Bridge"-Konzept ist. Default UTC.
@@ -20,6 +20,7 @@
20
20
  // When adding a symbol here, verify it's either a type or a pure
21
21
  // helper with no cross-module side-effects.
22
22
 
23
+ export { computeDerivedCellValue } from "../engine/embedded-derived";
23
24
  export type { ParsedRefTarget } from "../engine/parse-ref-target";
24
25
  export { parseRefTarget } from "../engine/parse-ref-target";
25
26
  export {