@cosmicdrift/kumiko-framework 0.221.0 → 0.222.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 (48) hide show
  1. package/package.json +7 -3
  2. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  3. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  4. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  5. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  6. package/src/api/auth-routes.ts +3 -0
  7. package/src/api/pii-leak-guard.ts +4 -5
  8. package/src/arg-parser.ts +1 -1
  9. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  10. package/src/db/entity-table-meta.ts +6 -1
  11. package/src/db/table-builder.ts +10 -3
  12. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  13. package/src/derivatives/derivatives-context.ts +4 -0
  14. package/src/derivatives/index.ts +9 -1
  15. package/src/derivatives/variant-key.ts +68 -0
  16. package/src/engine/__tests__/schema-builder.test.ts +4 -4
  17. package/src/engine/extensions/storage-provider.ts +28 -0
  18. package/src/engine/extensions/user-data.ts +4 -0
  19. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  20. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  21. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  22. package/src/engine/feature-ast/extractors/index.ts +2 -0
  23. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  24. package/src/engine/feature-ast/parse.ts +10 -25
  25. package/src/engine/feature-ast/patch.ts +14 -16
  26. package/src/engine/feature-ast/render.ts +3 -3
  27. package/src/engine/field-helpers.ts +1 -1
  28. package/src/engine/index.ts +5 -0
  29. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  30. package/src/engine/schema-builder.ts +14 -2
  31. package/src/errors/__tests__/classes.test.ts +16 -2
  32. package/src/errors/__tests__/write-failures.test.ts +10 -0
  33. package/src/errors/classes.ts +14 -13
  34. package/src/errors/write-error-info.ts +1 -1
  35. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  36. package/src/files/in-memory-provider.ts +4 -0
  37. package/src/files/local-provider.ts +22 -1
  38. package/src/jobs/job-runner.ts +22 -10
  39. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  40. package/src/pipeline/dispatch-shared.ts +39 -2
  41. package/src/pipeline/dispatch-write.ts +48 -0
  42. package/src/pipeline/dispatcher.ts +5 -0
  43. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  44. package/src/schema-cli.ts +30 -44
  45. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  46. package/src/stack/__tests__/request-helper.test.ts +2 -2
  47. package/src/testing/file-provider-contract.ts +19 -0
  48. package/src/upgrade-cli.ts +12 -1
@@ -1,5 +1,4 @@
1
- import type { CallExpression, Node, ObjectLiteralExpression, SourceFile } from "ts-morph";
2
- import { SyntaxKind } from "ts-morph";
1
+ import type { CallExpression, ObjectLiteralExpression, SourceFile } from "ts-morph";
3
2
  import type {
4
3
  AiClassifyPattern,
5
4
  AiExtractPattern,
@@ -17,7 +16,10 @@ import {
17
16
  isRawRefSentinel,
18
17
  ok,
19
18
  readDataLiteralNode,
19
+ readNameLiteral,
20
+ readObjectPropertyInitializer,
20
21
  readStringOrRaw,
22
+ resolveSameFileObjectLiteral,
21
23
  } from "./shared";
22
24
 
23
25
  type AiStepKind = AiGeneratePattern["kind"] | AiExtractPattern["kind"] | AiClassifyPattern["kind"];
@@ -32,33 +34,11 @@ type AiStepCommonExtracted = {
32
34
  readonly paramsSchemaSource?: SourceLocation;
33
35
  };
34
36
 
35
- function resolveObjectLiteralArg(node: Node): ObjectLiteralExpression | undefined {
36
- const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
37
- if (direct) return direct;
38
- const identifier = node.asKind(SyntaxKind.Identifier);
39
- if (!identifier) return undefined;
40
- const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
41
- return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
42
- }
43
-
44
- function readPropertyInitializer(
45
- obj: ObjectLiteralExpression,
46
- propertyName: string,
47
- ): import("ts-morph").Expression | undefined {
48
- const prop = obj.getProperty(propertyName);
49
- if (!prop) return undefined;
50
- const assign = prop.asKind(SyntaxKind.PropertyAssignment);
51
- if (assign) return assign.getInitializer();
52
- const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
53
- if (shorthand) return shorthand.getNameNode();
54
- return undefined;
55
- }
56
-
57
37
  function readEditableStringProp(
58
38
  obj: ObjectLiteralExpression,
59
39
  propertyName: string,
60
40
  ): string | AiStepOpaqueArgs | undefined {
61
- const init = readPropertyInitializer(obj, propertyName);
41
+ const init = readObjectPropertyInitializer(obj, propertyName);
62
42
  if (!init) return undefined;
63
43
  const value = readStringOrRaw(init);
64
44
  if (value === undefined) return undefined;
@@ -108,7 +88,7 @@ function extractAiStepCommon(
108
88
  return fail(kind, source, "expected one argument object");
109
89
  }
110
90
 
111
- const obj = resolveObjectLiteralArg(arg);
91
+ const obj = resolveSameFileObjectLiteral(arg);
112
92
  if (!obj) {
113
93
  if (isRawRefSentinel(readDataLiteralNode(arg))) {
114
94
  return ok({
@@ -123,15 +103,21 @@ function extractAiStepCommon(
123
103
  );
124
104
  }
125
105
 
126
- const stepKey = readEditableStringProp(obj, "stepKey");
127
- if (stepKey === undefined) {
106
+ const stepKeyInit = readObjectPropertyInitializer(obj, "stepKey");
107
+ if (!stepKeyInit) {
128
108
  return fail(kind, source, "missing `stepKey` property");
129
109
  }
110
+ // PatternId needs a concrete string — resolve identifiers like patch.readAiStepKey.
111
+ const stepKeyLiteral = readNameLiteral(stepKeyInit);
112
+ const stepKey = stepKeyLiteral ?? readStringOrRaw(stepKeyInit);
113
+ if (stepKey === undefined) {
114
+ return fail(kind, source, "`stepKey` must be a string literal or resolvable identifier");
115
+ }
130
116
  const promptKey = readEditableStringProp(obj, "promptKey");
131
117
  if (promptKey === undefined) {
132
118
  return fail(kind, source, "missing `promptKey` property");
133
119
  }
134
- const promptFallbackInit = readPropertyInitializer(obj, "promptFallback");
120
+ const promptFallbackInit = readObjectPropertyInitializer(obj, "promptFallback");
135
121
  if (!promptFallbackInit) {
136
122
  return fail(kind, source, "missing `promptFallback` property");
137
123
  }
@@ -140,7 +126,7 @@ function extractAiStepCommon(
140
126
  return fail(kind, source, "`promptFallback` must be a string literal or identifier reference");
141
127
  }
142
128
 
143
- const defaultsInit = readPropertyInitializer(obj, "defaults");
129
+ const defaultsInit = readObjectPropertyInitializer(obj, "defaults");
144
130
  if (!defaultsInit) {
145
131
  return fail(kind, source, "missing `defaults` property");
146
132
  }
@@ -149,7 +135,7 @@ function extractAiStepCommon(
149
135
  return fail(kind, source, "`defaults` could not be read as a StepPolicy literal");
150
136
  }
151
137
 
152
- const paramsSchemaInit = readPropertyInitializer(obj, "paramsSchema");
138
+ const paramsSchemaInit = readObjectPropertyInitializer(obj, "paramsSchema");
153
139
  if (!paramsSchemaInit) {
154
140
  return fail(kind, source, "missing `paramsSchema` property");
155
141
  }
@@ -206,11 +192,11 @@ export function extractAiGenerate(
206
192
  }
207
193
 
208
194
  const arg = call.getArguments()[0];
209
- const obj = arg ? resolveObjectLiteralArg(arg) : undefined;
195
+ const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
210
196
  if (!obj) {
211
197
  return fail("ai.generate", common.pattern.source, "expected resolvable argument object");
212
198
  }
213
- const inputInit = readPropertyInitializer(obj, "input");
199
+ const inputInit = readObjectPropertyInitializer(obj, "input");
214
200
  if (!inputInit) {
215
201
  return fail("ai.generate", common.pattern.source, "missing `input` property");
216
202
  }
@@ -252,16 +238,16 @@ export function extractAiExtract(
252
238
  }
253
239
 
254
240
  const arg = call.getArguments()[0];
255
- const obj = arg ? resolveObjectLiteralArg(arg) : undefined;
241
+ const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
256
242
  if (!obj) {
257
243
  return fail("ai.extract", common.pattern.source, "expected resolvable argument object");
258
244
  }
259
245
 
260
- const outputSchemaInit = readPropertyInitializer(obj, "outputSchema");
246
+ const outputSchemaInit = readObjectPropertyInitializer(obj, "outputSchema");
261
247
  if (!outputSchemaInit) {
262
248
  return fail("ai.extract", common.pattern.source, "missing `outputSchema` property");
263
249
  }
264
- const instructionsInit = readPropertyInitializer(obj, "instructions");
250
+ const instructionsInit = readObjectPropertyInitializer(obj, "instructions");
265
251
  if (!instructionsInit) {
266
252
  return fail("ai.extract", common.pattern.source, "missing `instructions` property");
267
253
  }
@@ -274,7 +260,7 @@ export function extractAiExtract(
274
260
  );
275
261
  }
276
262
 
277
- const documentInit = readPropertyInitializer(obj, "document");
263
+ const documentInit = readObjectPropertyInitializer(obj, "document");
278
264
  let documentBody: SourceLocation | undefined;
279
265
  if (documentInit) {
280
266
  const documentFn = findFunctionLiteral(documentInit);
@@ -319,12 +305,12 @@ export function extractAiClassify(
319
305
  }
320
306
 
321
307
  const arg = call.getArguments()[0];
322
- const obj = arg ? resolveObjectLiteralArg(arg) : undefined;
308
+ const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
323
309
  if (!obj) {
324
310
  return fail("ai.classify", common.pattern.source, "expected resolvable argument object");
325
311
  }
326
312
 
327
- const actionsInit = readPropertyInitializer(obj, "actions");
313
+ const actionsInit = readObjectPropertyInitializer(obj, "actions");
328
314
  if (!actionsInit) {
329
315
  return fail("ai.classify", common.pattern.source, "missing `actions` property");
330
316
  }
@@ -337,7 +323,7 @@ export function extractAiClassify(
337
323
  );
338
324
  }
339
325
 
340
- const inputInit = readPropertyInitializer(obj, "input");
326
+ const inputInit = readObjectPropertyInitializer(obj, "input");
341
327
  if (!inputInit) {
342
328
  return fail("ai.classify", common.pattern.source, "missing `input` property");
343
329
  }
@@ -80,7 +80,9 @@ export {
80
80
  readDataLiteralNode,
81
81
  readNameOrRef,
82
82
  readNameOrRefOrList,
83
+ readObjectPropertyInitializer,
83
84
  readPropertyKey,
84
85
  readStringLiteralArgs,
85
86
  readVarargsOrArrayProp,
87
+ resolveSameFileObjectLiteral,
86
88
  } from "./shared";
@@ -1,4 +1,4 @@
1
- import type { CallExpression, Node } from "ts-morph";
1
+ import type { CallExpression, Node, ObjectLiteralExpression } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import { isPlainObject } from "../../../utils/is-plain-object";
4
4
  import type { ParseError } from "../parse";
@@ -264,6 +264,31 @@ export function readNameOrRef(node: Node): string | undefined {
264
264
  return undefined;
265
265
  }
266
266
 
267
+ /** Resolve an inline object literal or a same-file `const` that initializes to one. */
268
+ export function resolveSameFileObjectLiteral(node: Node): ObjectLiteralExpression | undefined {
269
+ const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
270
+ if (direct) return direct;
271
+ const identifier = node.asKind(SyntaxKind.Identifier);
272
+ if (!identifier) return undefined;
273
+ const valueDecl = identifier.getSymbol()?.getValueDeclaration();
274
+ const fromSymbol = valueDecl?.asKind(SyntaxKind.VariableDeclaration);
275
+ const varDecl = fromSymbol ?? node.getSourceFile().getVariableDeclaration(identifier.getText());
276
+ return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
277
+ }
278
+
279
+ export function readObjectPropertyInitializer(
280
+ obj: ObjectLiteralExpression,
281
+ propertyName: string,
282
+ ): import("ts-morph").Expression | undefined {
283
+ const prop = obj.getProperty(propertyName);
284
+ if (!prop) return undefined;
285
+ const assign = prop.asKind(SyntaxKind.PropertyAssignment);
286
+ if (assign) return assign.getInitializer();
287
+ const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
288
+ if (shorthand) return shorthand.getNameNode();
289
+ return undefined;
290
+ }
291
+
267
292
  export function findFunctionLiteral(node: Node): Node | undefined {
268
293
  if (node.getKind() === SyntaxKind.ArrowFunction) return node;
269
294
  if (node.getKind() === SyntaxKind.FunctionExpression) return node;
@@ -26,7 +26,6 @@ import type {
26
26
  CallExpression,
27
27
  Expression,
28
28
  Node,
29
- ObjectLiteralExpression,
30
29
  ParameterDeclaration,
31
30
  SourceFile,
32
31
  } from "ts-morph";
@@ -74,6 +73,8 @@ import {
74
73
  extractWorkspace,
75
74
  extractWriteHandler,
76
75
  findFunctionLiteral,
76
+ readObjectPropertyInitializer,
77
+ resolveSameFileObjectLiteral,
77
78
  } from "./extractors";
78
79
  import type { FeaturePattern, UnknownPattern } from "./patterns";
79
80
  import { type SourceLocation, sourceLocationFromNode } from "./source-location";
@@ -158,7 +159,13 @@ export function parseSourceFile(sourceFile: SourceFile): ParseResult {
158
159
 
159
160
  walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
160
161
  walkAiStepCalls(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
161
- patterns.sort((a, b) => a.source.start.line - b.source.start.line);
162
+ // Tie-break on filePath so cross-file registrar wrappers don't interleave by
163
+ // foreign line numbers alone.
164
+ patterns.sort((a, b) => {
165
+ const fileCmp = a.source.file.localeCompare(b.source.file);
166
+ if (fileCmp !== 0) return fileCmp;
167
+ return a.source.start.line - b.source.start.line;
168
+ });
162
169
 
163
170
  return { featureName, patterns, errors };
164
171
  }
@@ -387,28 +394,6 @@ function extractRegistrarMethodName(
387
394
  return propAccess.getName();
388
395
  }
389
396
 
390
- function readObjectPropertyInitializer(
391
- obj: ObjectLiteralExpression,
392
- propertyName: string,
393
- ): Expression | undefined {
394
- const prop = obj.getProperty(propertyName);
395
- if (!prop) return undefined;
396
- const assign = prop.asKind(SyntaxKind.PropertyAssignment);
397
- if (assign) return assign.getInitializer();
398
- const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
399
- if (shorthand) return shorthand.getNameNode();
400
- return undefined;
401
- }
402
-
403
- function resolveSameFileObjectLiteralArg(node: Node): ObjectLiteralExpression | undefined {
404
- const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
405
- if (direct) return direct;
406
- const identifier = node.asKind(SyntaxKind.Identifier);
407
- if (!identifier) return undefined;
408
- const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
409
- return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
410
- }
411
-
412
397
  function resolveStepsArrayRoot(stepsInit: Expression): Node | undefined {
413
398
  const directArray = stepsInit.asKind(SyntaxKind.ArrayLiteralExpression);
414
399
  if (directArray) return directArray;
@@ -444,7 +429,7 @@ function collectWorkflowStepArrayRoots(body: Node): Node[] {
444
429
  const roots: Node[] = [];
445
430
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
446
431
  if (call.getExpression().getText() !== "defineWorkflow") continue;
447
- const obj = resolveSameFileObjectLiteralArg(call.getArguments()[0] ?? call);
432
+ const obj = resolveSameFileObjectLiteral(call.getArguments()[0] ?? call);
448
433
  if (!obj) continue;
449
434
  const stepsInit = readObjectPropertyInitializer(obj, "steps");
450
435
  if (!stepsInit) continue;
@@ -30,9 +30,8 @@
30
30
  // roadmap C-Notes for the canonical-comment-attach Pattern that would
31
31
  // preserve prefixed `// kumiko-comment:` markers across roundtrips.
32
32
 
33
- import type { ObjectLiteralExpression } from "ts-morph";
34
33
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
35
- import { readNameLiteral, readNameOrRef } from "./extractors/shared";
34
+ import { readNameLiteral, readNameOrRef, resolveSameFileObjectLiteral } from "./extractors/shared";
36
35
  import type { FeaturePattern, FeaturePatternKind } from "./patterns";
37
36
  import { indent, PATTERN_INDENT, renderPattern } from "./render";
38
37
 
@@ -204,7 +203,9 @@ export function replacePattern(
204
203
  const startNode = isAiStepId(id) ? call : (enclosingStatement ?? call);
205
204
 
206
205
  const startPos = startNode.getStart();
207
- const endPos = isAiStepId(id) ? aiStepPatchSpan(sourceFile, call).end : startNode.getEnd();
206
+ // AI steps live in array literals replace only the CallExpression so the
207
+ // surrounding comma stays (comma-eating belongs to removePattern alone).
208
+ const endPos = isAiStepId(id) ? call.getEnd() : startNode.getEnd();
208
209
 
209
210
  // Detect column of the original call's first non-whitespace character;
210
211
  // the rendered pattern starts at column 0 and gets indented to match.
@@ -366,17 +367,6 @@ const AI_STEP_FACTORY: Readonly<Record<"ai.generate" | "ai.extract" | "ai.classi
366
367
  "ai.classify": "aiClassifyStep",
367
368
  };
368
369
 
369
- function resolveSameFileObjectLiteral(
370
- node: import("ts-morph").Node,
371
- ): ObjectLiteralExpression | undefined {
372
- const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
373
- if (direct) return direct;
374
- const identifier = node.asKind(SyntaxKind.Identifier);
375
- if (!identifier) return undefined;
376
- const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
377
- return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
378
- }
379
-
380
370
  function readAiStepKey(call: CallExpression): string | undefined {
381
371
  const arg = call.getArguments()[0];
382
372
  if (!arg) return undefined;
@@ -400,11 +390,19 @@ function findAiStepCall(sourceFile: SourceFile, id: PatternId): CallExpression |
400
390
  return undefined;
401
391
  }
402
392
  const factory = AI_STEP_FACTORY[id.kind];
393
+ const matches: CallExpression[] = [];
403
394
  for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
404
395
  if (call.getExpression().getText() !== factory) continue;
405
- if (callMatchesId(call, id)) return call;
396
+ if (callMatchesId(call, id)) matches.push(call);
406
397
  }
407
- return undefined;
398
+ if (matches.length > 1) {
399
+ throw new Error(
400
+ `findAiStepCall: ambiguous ${id.kind} stepKey=${JSON.stringify(
401
+ "stepKey" in id ? id.stepKey : undefined,
402
+ )} — ${matches.length} matches; disambiguate or include workflowName in PatternId`,
403
+ );
404
+ }
405
+ return matches[0];
408
406
  }
409
407
 
410
408
  function callMatchesId(call: CallExpression, id: PatternId): boolean {
@@ -481,7 +481,7 @@ function renderAiGenerate(p: AiGeneratePattern): string {
481
481
  const lines: string[] = ["aiGenerateStep({"];
482
482
  renderAiStepCommonFields(lines, p);
483
483
  if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
484
- lines.push("});");
484
+ lines.push("})");
485
485
  return lines.join("\n");
486
486
  }
487
487
 
@@ -496,7 +496,7 @@ function renderAiExtract(p: AiExtractPattern): string {
496
496
  lines.push(` instructions: ${p.instructionsBody.raw},`);
497
497
  }
498
498
  if (p.documentBody !== undefined) lines.push(` document: ${p.documentBody.raw},`);
499
- lines.push("});");
499
+ lines.push("})");
500
500
  return lines.join("\n");
501
501
  }
502
502
 
@@ -506,7 +506,7 @@ function renderAiClassify(p: AiClassifyPattern): string {
506
506
  renderAiStepCommonFields(lines, p);
507
507
  if (p.actions !== undefined) lines.push(` actions: ${renderValue(p.actions)},`);
508
508
  if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
509
- lines.push("});");
509
+ lines.push("})");
510
510
  return lines.join("\n");
511
511
  }
512
512
 
@@ -22,7 +22,7 @@ export const DEFAULT_CURRENCIES = [
22
22
 
23
23
  // --- Locale ---
24
24
 
25
- export const DEFAULT_LOCALES = ["de", "en"] as const;
25
+ export const DEFAULT_LOCALES = ["de", "en", "es"] as const;
26
26
 
27
27
  export function isFileField(field: FieldDefinition | undefined): field is AnyFileFieldDef {
28
28
  if (!field) return false;
@@ -96,6 +96,11 @@ export {
96
96
  EXT_USER_DATA_ORDER,
97
97
  FILE_PROVIDER_CONFIG_KEY,
98
98
  } from "./extension-names";
99
+ export type {
100
+ StorageProviderDestroyTenantHook,
101
+ StorageProviderExtensionHooks,
102
+ StorageProviderHookCtx,
103
+ } from "./extensions/storage-provider";
99
104
  export type {
100
105
  TenantDataDestroyHook,
101
106
  TenantDataExtensionHooks,
@@ -243,6 +243,12 @@ const aiStepCommonFields = [
243
243
  input: "text",
244
244
  required: true,
245
245
  },
246
+ {
247
+ path: "promptFallback",
248
+ label: { en: "Prompt fallback", de: "Prompt-Fallback" },
249
+ input: "text",
250
+ required: true,
251
+ },
246
252
  {
247
253
  path: "defaults",
248
254
  label: { en: "Defaults", de: "Defaults" },
@@ -86,6 +86,7 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
86
86
  }
87
87
  }
88
88
 
89
+ // kumiko-lint-ignore complexity-budget field→zod switch is the single schema source of truth
89
90
  export function fieldToZod(
90
91
  field: FieldDefinition,
91
92
  currencies: readonly string[],
@@ -315,8 +316,19 @@ function applyTotalsMatchRefinements(
315
316
  for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
316
317
  const rawRows = values[fieldName];
317
318
  const siblingRaw = values[siblingFieldName];
318
- // Not sent -> not checkable, not an error (partial update payloads).
319
- if (rawRows === undefined || siblingRaw === undefined) continue;
319
+ // Neither side sent nothing to check (unrelated partial update).
320
+ if (rawRows === undefined && siblingRaw === undefined) continue;
321
+ // One side of a totalsMatch pair without the other → reject. Update
322
+ // payloads only carry `changes`, so omitting the sibling would otherwise
323
+ // silently leave sum ≠ total (fw#1841).
324
+ if (rawRows === undefined || siblingRaw === undefined) {
325
+ ctx.addIssue({
326
+ code: "custom",
327
+ path: [rawRows === undefined ? fieldName : siblingFieldName],
328
+ message: `totalsMatch requires both "${fieldName}" and "${siblingFieldName}" in the same payload`,
329
+ });
330
+ continue;
331
+ }
320
332
  // Not an array -> a different refinement already rejects the shape;
321
333
  // this check isn't the right place to report it.
322
334
  if (!Array.isArray(rawRows)) continue;
@@ -9,6 +9,7 @@ import {
9
9
  KumikoError,
10
10
  NotFoundError,
11
11
  serializeError,
12
+ UnconfiguredError,
12
13
  UnprocessableError,
13
14
  ValidationError,
14
15
  VersionConflictError,
@@ -329,9 +330,9 @@ describe("UnprocessableError", () => {
329
330
  expect(err.i18nKey).toBe("orders.errors.alreadyCancelled");
330
331
  });
331
332
 
332
- test("positional reason survives a conflicting details.reason from the caller", () => {
333
+ test("positional reason is the sole details.reason (extras allowed)", () => {
333
334
  const err = new UnprocessableError("order.already_cancelled", {
334
- details: { reason: "some unrelated cause text", orderId: 7 },
335
+ details: { orderId: 7 },
335
336
  });
336
337
  expect(err.details).toEqual({ reason: "order.already_cancelled", orderId: 7 });
337
338
  expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order.already_cancelled");
@@ -426,3 +427,16 @@ class KumikoErrorStub extends KumikoError {
426
427
  super({ message: "stub", i18nKey: "stub", cause: opts.cause });
427
428
  }
428
429
  }
430
+
431
+ describe("UnconfiguredError", () => {
432
+ test("docsUrl uses stable unconfigured slug, not the freestext message", () => {
433
+ const err = new UnconfiguredError({ feature: "billing", key: "apiKey" });
434
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/unconfigured");
435
+ expect(err.details).toMatchObject({
436
+ reason: "unconfigured",
437
+ feature: "billing",
438
+ key: "apiKey",
439
+ });
440
+ expect(String((err.details as { message?: string }).message)).toContain("apiKey");
441
+ });
442
+ });
@@ -19,9 +19,19 @@ describe("failUnprocessable", () => {
19
19
  });
20
20
 
21
21
  test("positional reason survives a conflicting details.reason from the caller", () => {
22
+ // @ts-expect-error callers must not pass details.reason; positional arg wins at runtime
22
23
  const f = failUnprocessable("custom_business_rule", { reason: "raw cause text", extra: 42 });
23
24
  expect(f.error.details).toEqual({ reason: "custom_business_rule", extra: 42 });
24
25
  });
26
+
27
+ test("details may carry extras but reason comes only from the positional arg", () => {
28
+ const f = failUnprocessable("custom_business_rule", { extra: 42, causeNote: "use opts.cause" });
29
+ expect(f.error.details).toEqual({
30
+ reason: "custom_business_rule",
31
+ extra: 42,
32
+ causeNote: "use opts.cause",
33
+ });
34
+ });
25
35
  });
26
36
 
27
37
  describe("failTransition", () => {
@@ -192,8 +192,9 @@ export class UniqueViolationError extends ConflictError {
192
192
 
193
193
  // Business-rule violation. The human-readable reason lives in details.reason
194
194
  // so the client can key off it without overloading the top-level code.
195
- export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "cause"> & {
196
- readonly details?: Readonly<Record<string, unknown>>;
195
+ export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "cause" | "message"> & {
196
+ // `reason` is owned by the positional ctor arg — callers put cause text in `opts.cause`.
197
+ readonly details?: Readonly<Record<string, unknown>> & { readonly reason?: never };
197
198
  };
198
199
 
199
200
  export class UnprocessableError extends KumikoError {
@@ -204,7 +205,7 @@ export class UnprocessableError extends KumikoError {
204
205
 
205
206
  constructor(reason: string, opts?: UnprocessableOpts) {
206
207
  super({
207
- message: `unprocessable: ${reason}`,
208
+ message: opts?.message ?? `unprocessable: ${reason}`,
208
209
  i18nKey: opts?.i18nKey ?? "errors.unprocessable",
209
210
  ...(opts?.i18nParams && { i18nParams: opts.i18nParams }),
210
211
  details: { ...opts?.details, reason },
@@ -226,16 +227,16 @@ export class UnconfiguredError extends UnprocessableError {
226
227
  override readonly code: string = "unconfigured";
227
228
 
228
229
  constructor(details: UnconfiguredDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
229
- super(
230
- `${details.feature}: '${details.key}' is empty — tenant must configure it before use.${
231
- details.hint ? ` ${details.hint}` : ""
232
- }`,
233
- {
234
- i18nKey: opts?.i18nKey ?? "errors.unconfigured",
235
- details,
236
- ...(opts?.cause && { cause: opts.cause }),
237
- },
238
- );
230
+ const message = `${details.feature}: '${details.key}' is empty — tenant must configure it before use.${
231
+ details.hint ? ` ${details.hint}` : ""
232
+ }`;
233
+ // Stable slug for docsUrl — freestext is the Error.message + details.message.
234
+ super("unconfigured", {
235
+ message,
236
+ i18nKey: opts?.i18nKey ?? "errors.unconfigured",
237
+ details: { ...details, message },
238
+ ...(opts?.cause && { cause: opts.cause }),
239
+ });
239
240
  }
240
241
  }
241
242
 
@@ -35,7 +35,7 @@ export function failNotFound(entity: string, id?: number | string): WriteFailure
35
35
  // @wrapper-known error-helper
36
36
  export function failUnprocessable(
37
37
  reason: string,
38
- details?: Readonly<Record<string, unknown>>,
38
+ details?: Readonly<Record<string, unknown>> & { readonly reason?: never },
39
39
  ): WriteFailure {
40
40
  return writeFailure(new UnprocessableError(reason, details ? { details } : undefined));
41
41
  }
@@ -0,0 +1,14 @@
1
+ import { afterAll } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describeFileProviderContract } from "../../testing/file-provider-contract";
6
+ import { createLocalProvider } from "../local-provider";
7
+
8
+ const basePath = join(tmpdir(), `kumiko-local-provider-contract-${Date.now()}`);
9
+
10
+ describeFileProviderContract("LocalFileProvider", () => createLocalProvider(basePath));
11
+
12
+ afterAll(async () => {
13
+ await rm(basePath, { recursive: true, force: true });
14
+ });
@@ -86,6 +86,10 @@ export function createInMemoryFileProvider(): InMemoryFileProvider {
86
86
  return store.has(key);
87
87
  },
88
88
 
89
+ async list(prefix) {
90
+ return Array.from(store.keys()).filter((key) => key.startsWith(prefix));
91
+ },
92
+
89
93
  // Deterministic fake URL — encodes the key + expiry so tests can assert
90
94
  // the route wired through without running a real presigner. Shape
91
95
  // (memory://<key>?expires=<seconds>) intentionally differs from any real
@@ -1,5 +1,5 @@
1
1
  import { createReadStream, createWriteStream } from "node:fs";
2
- import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { assertSafeStorageKey, type FileStorageProvider } from "./types";
@@ -107,5 +107,26 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
107
107
  return false;
108
108
  }
109
109
  },
110
+
111
+ async list(prefix: string): Promise<readonly string[]> {
112
+ // recursive:true returns POSIX- or OS-sep-joined relative paths for
113
+ // both files and directories; normalize to "/" (storage keys are
114
+ // always "/"-joined, matching S3) before the prefix match, then stat
115
+ // only the (few) matches to drop directory entries.
116
+ let entries: string[];
117
+ try {
118
+ entries = await readdir(resolvedBase, { recursive: true });
119
+ } catch (err) {
120
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
121
+ throw err;
122
+ }
123
+ const results: string[] = [];
124
+ for (const entry of entries) {
125
+ const key = entry.split(sep).join("/");
126
+ if (!key.startsWith(prefix)) continue;
127
+ if ((await stat(join(resolvedBase, entry))).isFile()) results.push(key);
128
+ }
129
+ return results;
130
+ },
110
131
  };
111
132
  }