@cosmicdrift/kumiko-framework 0.183.2 → 0.185.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,
@@ -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
+ });
@@ -168,20 +168,24 @@ export async function buildHandlerContext(
168
168
  // but at this point we're the root of the pipeline — cast is safe.
169
169
  const dbSource = resolveDbSource(ctx, tx);
170
170
  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;
171
+ const buildTenantScopedDb = (source: DbConnection | DbTx) =>
172
+ createTenantDb(
173
+ source,
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
+ const db = dbSource ? buildTenantScopedDb(dbSource) : undefined;
185
+ // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
186
+ // through it survive a rollback of the handler's own transaction.
187
+ const outsideTxSource = resolveDbSource(ctx, undefined);
188
+ const dbOutsideTransaction = outsideTxSource ? buildTenantScopedDb(outsideTxSource) : undefined;
185
189
  const log = context.log?.child({
186
190
  handler: type,
187
191
  tenantId: user.tenantId,
@@ -548,6 +552,7 @@ export async function buildHandlerContext(
548
552
  ...context,
549
553
  registry,
550
554
  db,
555
+ dbOutsideTransaction,
551
556
  log,
552
557
  notify,
553
558
  ...(config && { config }),
@@ -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 {