@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.
- package/package.json +3 -3
- package/src/db/__tests__/compound-types.test.ts +2 -2
- package/src/db/__tests__/money.test.ts +30 -14
- package/src/db/money.ts +6 -3
- package/src/engine/__tests__/boot-validator.test.ts +112 -16
- package/src/engine/__tests__/engine.test.ts +183 -0
- package/src/engine/__tests__/schema-builder.test.ts +218 -0
- package/src/engine/boot-validator/entity-handler.ts +247 -65
- package/src/engine/boot-validator/index.ts +1 -1
- package/src/engine/embedded-derived.ts +51 -0
- package/src/engine/schema-builder.ts +81 -6
- package/src/engine/types/index.ts +1 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +34 -0
- package/src/pipeline/dispatch-shared.ts +19 -14
- package/src/ui-types/index.ts +1 -0
|
@@ -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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
}
|
|
@@ -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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 }),
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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 {
|