@cosmicdrift/kumiko-framework 0.220.1 → 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 (110) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  5. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  7. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  8. package/src/api/api-constants.ts +10 -0
  9. package/src/api/auth-routes.ts +3 -0
  10. package/src/api/index.ts +1 -0
  11. package/src/api/pii-leak-guard.ts +4 -5
  12. package/src/api/server.ts +17 -1
  13. package/src/arg-parser.ts +1 -1
  14. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  15. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  16. package/src/bun-db/index.ts +1 -0
  17. package/src/bun-db/query.ts +30 -14
  18. package/src/crypto/index.ts +1 -0
  19. package/src/crypto/is-self-pii-field.ts +8 -0
  20. package/src/crypto/subject-resolver.ts +4 -3
  21. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  22. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  23. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  24. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  25. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  26. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  27. package/src/db/blind-index-cleanup.ts +36 -19
  28. package/src/db/entity-table-meta.ts +6 -1
  29. package/src/db/event-store-executor-context.ts +2 -2
  30. package/src/db/event-store-executor-read.ts +7 -6
  31. package/src/db/event-store-executor-write.ts +103 -49
  32. package/src/db/index.ts +2 -0
  33. package/src/db/migrate-generator.ts +14 -0
  34. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  35. package/src/db/queries/backfill-pii.ts +13 -10
  36. package/src/db/queries/raw-sql.ts +14 -2
  37. package/src/db/queries/seed-context.ts +8 -4
  38. package/src/db/table-builder.ts +10 -3
  39. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  40. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  41. package/src/derivatives/derivatives-context.ts +4 -0
  42. package/src/derivatives/index.ts +9 -1
  43. package/src/derivatives/variant-key.ts +68 -0
  44. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  45. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  46. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  47. package/src/engine/__tests__/schema-builder.test.ts +7 -7
  48. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  49. package/src/engine/boot-validator/entity-handler.ts +5 -0
  50. package/src/engine/boot-validator/pii-retention.ts +16 -4
  51. package/src/engine/boot-validator/screens.ts +9 -2
  52. package/src/engine/embedded-derived.ts +11 -10
  53. package/src/engine/extensions/storage-provider.ts +28 -0
  54. package/src/engine/extensions/user-data.ts +4 -0
  55. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  56. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  57. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  58. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  59. package/src/engine/feature-ast/extractors/index.ts +2 -0
  60. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  61. package/src/engine/feature-ast/parse.ts +10 -25
  62. package/src/engine/feature-ast/patch.ts +23 -16
  63. package/src/engine/feature-ast/render.ts +3 -3
  64. package/src/engine/field-helpers.ts +1 -1
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  67. package/src/engine/role-assignment.ts +36 -18
  68. package/src/engine/schema-builder.ts +14 -2
  69. package/src/errors/__tests__/classes.test.ts +21 -2
  70. package/src/errors/__tests__/write-failures.test.ts +13 -3
  71. package/src/errors/classes.ts +14 -13
  72. package/src/errors/kumiko-error.ts +11 -11
  73. package/src/errors/write-error-info.ts +1 -1
  74. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  75. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  76. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  77. package/src/event-store/event-store.ts +7 -0
  78. package/src/event-store/index.ts +1 -0
  79. package/src/files/__tests__/files.integration.test.ts +181 -2
  80. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  81. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  82. package/src/files/file-routes.ts +53 -6
  83. package/src/files/in-memory-provider.ts +4 -0
  84. package/src/files/local-provider.ts +22 -1
  85. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  86. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  87. package/src/i18n/index.ts +7 -1
  88. package/src/i18n/mail-registry.ts +10 -0
  89. package/src/i18n/request-locale.ts +11 -2
  90. package/src/i18n/required-surface-keys.ts +3 -1
  91. package/src/jobs/job-runner.ts +22 -10
  92. package/src/lifecycle/signal-handlers.ts +2 -0
  93. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  94. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  95. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  96. package/src/pipeline/dispatch-shared.ts +39 -2
  97. package/src/pipeline/dispatch-write.ts +48 -0
  98. package/src/pipeline/dispatcher.ts +5 -0
  99. package/src/pipeline/distributed-lock.ts +3 -0
  100. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  101. package/src/schema-cli.ts +39 -48
  102. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  103. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  104. package/src/search/purge-subject.ts +4 -3
  105. package/src/search/reindex-entity.ts +2 -2
  106. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  107. package/src/stack/__tests__/request-helper.test.ts +2 -2
  108. package/src/testing/file-provider-contract.ts +19 -0
  109. package/src/ui-types/index.ts +1 -0
  110. package/src/upgrade-cli.ts +112 -15
@@ -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
 
@@ -147,6 +146,15 @@ export function applyChanges(sourceFile: SourceFile, changes: readonly PatternCh
147
146
  * biome-stable formatting that matches the renderFeatureFile output.
148
147
  */
149
148
  export function addPattern(sourceFile: SourceFile, pattern: FeaturePattern): void {
149
+ if (
150
+ pattern.kind === "ai.generate" ||
151
+ pattern.kind === "ai.extract" ||
152
+ pattern.kind === "ai.classify"
153
+ ) {
154
+ throw new Error(
155
+ `addPattern: ${pattern.kind} steps must be inserted into a defineWorkflow steps array, not the setup body`,
156
+ );
157
+ }
150
158
  const setup = findSetupCallback(sourceFile);
151
159
  if (!setup) {
152
160
  throw new Error("addPattern: no defineFeature(name, (r) => { ... }) call found");
@@ -195,7 +203,9 @@ export function replacePattern(
195
203
  const startNode = isAiStepId(id) ? call : (enclosingStatement ?? call);
196
204
 
197
205
  const startPos = startNode.getStart();
198
- 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();
199
209
 
200
210
  // Detect column of the original call's first non-whitespace character;
201
211
  // the rendered pattern starts at column 0 and gets indented to match.
@@ -357,17 +367,6 @@ const AI_STEP_FACTORY: Readonly<Record<"ai.generate" | "ai.extract" | "ai.classi
357
367
  "ai.classify": "aiClassifyStep",
358
368
  };
359
369
 
360
- function resolveSameFileObjectLiteral(
361
- node: import("ts-morph").Node,
362
- ): ObjectLiteralExpression | undefined {
363
- const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
364
- if (direct) return direct;
365
- const identifier = node.asKind(SyntaxKind.Identifier);
366
- if (!identifier) return undefined;
367
- const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
368
- return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
369
- }
370
-
371
370
  function readAiStepKey(call: CallExpression): string | undefined {
372
371
  const arg = call.getArguments()[0];
373
372
  if (!arg) return undefined;
@@ -391,11 +390,19 @@ function findAiStepCall(sourceFile: SourceFile, id: PatternId): CallExpression |
391
390
  return undefined;
392
391
  }
393
392
  const factory = AI_STEP_FACTORY[id.kind];
393
+ const matches: CallExpression[] = [];
394
394
  for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
395
395
  if (call.getExpression().getText() !== factory) continue;
396
- if (callMatchesId(call, id)) return call;
396
+ if (callMatchesId(call, id)) matches.push(call);
397
397
  }
398
- 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];
399
406
  }
400
407
 
401
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" },
@@ -1,45 +1,63 @@
1
- const ROLE_RANKS: Readonly<Record<string, number>> = {
2
- User: 0,
1
+ // Prototype-free rank table Object literals leak Object.prototype
2
+ // (`constructor`, `toString`, …) into `ROLE_RANKS[role]` lookups and turn
3
+ // Math.max into NaN, which fails every `rank > actorRank` check open.
4
+ const ROLE_RANKS = new Map<string, number>([
5
+ ["User", 0],
3
6
  // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
4
- Editor: 1,
5
- Admin: 2,
6
- TenantAdmin: 3,
7
- SystemAdmin: 4,
8
- system: 5,
9
- };
7
+ ["Editor", 1],
8
+ ["Admin", 2],
9
+ ["TenantAdmin", 3],
10
+ ["SystemAdmin", 4],
11
+ ["system", 5],
12
+ ]);
13
+
14
+ // Unknown roles: +∞ on the assigned path (cannot grant what we don't know),
15
+ // -1 on the actor path (cannot elevate via an unrecognized self-role).
16
+ function roleRankOr(role: string, unknownRank: number): number {
17
+ return ROLE_RANKS.get(role) ?? unknownRank;
18
+ }
10
19
 
11
20
  function getRoleRank(role: string): number {
12
- return ROLE_RANKS[role] ?? Number.POSITIVE_INFINITY;
21
+ return roleRankOr(role, Number.POSITIVE_INFINITY);
13
22
  }
14
23
 
15
24
  function maxRoleRank(roles: readonly string[]): number {
16
25
  if (roles.length === 0) return -1;
17
- return Math.max(...roles.map((role) => ROLE_RANKS[role] ?? -1));
26
+ return Math.max(...roles.map((role) => roleRankOr(role, -1)));
18
27
  }
19
28
 
20
29
  /** Known built-in rank only — unranked app roles (Billing, …) are not privilege tiers. */
21
30
  function knownRoleRank(role: string): number | undefined {
22
- return ROLE_RANKS[role];
31
+ return ROLE_RANKS.get(role);
23
32
  }
24
33
 
25
34
  export function findForbiddenRoleAssignment(
26
35
  actorRoles: readonly string[],
27
36
  assignedRoles: readonly string[],
28
- targetCurrentRoles: readonly string[] = [],
37
+ // Required so callers cannot accidentally disable the downgrade/
38
+ // takeover guard by omitting the third argument (new users: pass []).
39
+ targetCurrentRoles: readonly string[],
29
40
  ): string | undefined {
30
41
  const actorRank = maxRoleRank(actorRoles);
31
- // Assign path: fail-closed on unknown / above-actor roles.
32
- const forbiddenAssigned = assignedRoles.find((role) => getRoleRank(role) > actorRank);
33
- if (forbiddenAssigned) return forbiddenAssigned;
42
+ // Assign path: fail-closed on unknown / above-actor roles — except unranked
43
+ // app roles the target already holds (round-trip restore after strip).
44
+ const forbiddenAssigned = assignedRoles.find((role) => {
45
+ if (getRoleRank(role) <= actorRank) return false;
46
+ if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role)) return false;
47
+ return true;
48
+ });
49
+ // Empty string is unknown (rank +∞) but falsy — must not use truthiness.
50
+ if (forbiddenAssigned !== undefined) return forbiddenAssigned;
34
51
 
35
52
  // Target path: only ranked roles above the actor block (can't touch a
36
- // SystemAdmin). Unranked app roles must not block demotion/updates invite
37
- // can assign them; treating them as rank ∞ on the target freezes those members.
53
+ // SystemAdmin). Unranked app roles are ignored here so members with only
54
+ // those roles don't freeze on demotion/updates the assign path still
55
+ // rejects *new* unranked roles fail-closed.
38
56
  const forbiddenTarget = targetCurrentRoles.find((role) => {
39
57
  const rank = knownRoleRank(role);
40
58
  return rank !== undefined && rank > actorRank;
41
59
  });
42
- if (forbiddenTarget) return forbiddenTarget;
60
+ if (forbiddenTarget !== undefined) return forbiddenTarget;
43
61
 
44
62
  return undefined;
45
63
  }
@@ -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,
@@ -65,6 +66,11 @@ describe("KumikoError: abstract base", () => {
65
66
  const body = serializeError(err);
66
67
  expect(body.error.docsUrl).toBe("https://docs.kumiko.rocks/errors/stale_state");
67
68
  });
69
+
70
+ test("falls back to code when reason slug is not URL-safe", () => {
71
+ const err = new ConflictError({ details: { reason: "stale state/../x" } });
72
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/conflict");
73
+ });
68
74
  });
69
75
  });
70
76
 
@@ -324,9 +330,9 @@ describe("UnprocessableError", () => {
324
330
  expect(err.i18nKey).toBe("orders.errors.alreadyCancelled");
325
331
  });
326
332
 
327
- test("positional reason survives a conflicting details.reason from the caller", () => {
333
+ test("positional reason is the sole details.reason (extras allowed)", () => {
328
334
  const err = new UnprocessableError("order.already_cancelled", {
329
- details: { reason: "some unrelated cause text", orderId: 7 },
335
+ details: { orderId: 7 },
330
336
  });
331
337
  expect(err.details).toEqual({ reason: "order.already_cancelled", orderId: 7 });
332
338
  expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order.already_cancelled");
@@ -421,3 +427,16 @@ class KumikoErrorStub extends KumikoError {
421
427
  super({ message: "stub", i18nKey: "stub", cause: opts.cause });
422
428
  }
423
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
+ });
@@ -12,20 +12,30 @@ describe("failNotFound", () => {
12
12
  });
13
13
 
14
14
  describe("failUnprocessable", () => {
15
- test("baut WriteFailure mit reason + custom-details", () => {
15
+ test("builds WriteFailure with reason + custom details", () => {
16
16
  const f = failUnprocessable("custom_business_rule", { extra: 42 });
17
17
  expect(f.error.httpStatus).toBe(422);
18
18
  expect(f.error.details).toMatchObject({ reason: "custom_business_rule", extra: 42 });
19
19
  });
20
20
 
21
- test("reason-Argument überlebt ein details.reason aus dem Aufruf", () => {
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", () => {
28
- test("baut WriteFailure mit reason=invalid_transition + from/to/allowed", () => {
38
+ test("builds WriteFailure with reason=invalid_transition + from/to/allowed", () => {
29
39
  const f = failTransition("draft", "paid", ["sent"]);
30
40
  expect(f.isSuccess).toBe(false);
31
41
  expect(f.error.code).toBe("unprocessable");
@@ -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
 
@@ -19,10 +19,10 @@ export type ErrorCtorInput = {
19
19
  readonly cause?: Error;
20
20
  };
21
21
 
22
- // Default-Doku-URL für Self-Service-Errors. Kann via env-var
23
- // `KUMIKO_DOCS_URL` überschrieben werden z.B. für Self-Hosted-Kunden
24
- // die ihre eigene Doku-Instanz hosten.
22
+ // Default docs URL for self-service errors. Override via `KUMIKO_DOCS_URL`
23
+ // (e.g. self-hosted customers pointing at their own docs instance).
25
24
  const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
25
+ const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
26
26
 
27
27
  function docsBaseUrl(): string {
28
28
  return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
@@ -43,20 +43,20 @@ export abstract class KumikoError extends Error {
43
43
  this.details = input.details;
44
44
  }
45
45
 
46
- // Doku-URL für Self-Service. Pro-Reason-Slug aus `details.reason` wenn
47
- // vorhanden (z.B. ConflictError → "stale_state"), sonst Fallback auf
48
- // den Error-Code (z.B. "not_found", "validation_error"). Default-Renderer
49
- // im Client zeigt "Mehr erfahren →" Link auf diese URL.
46
+ // Docs URL for self-service. Prefer a well-formed `details.reason` slug
47
+ // (e.g. ConflictError → "stale_state"); otherwise fall back to the error
48
+ // code. Encode the path segment so spaces/`../` in free-form reasons
49
+ // cannot break or redirect the link the default renderer shows.
50
50
  get docsUrl(): string {
51
- return `${docsBaseUrl()}/errors/${this.reasonSlug}`;
51
+ return `${docsBaseUrl()}/errors/${encodeURIComponent(this.reasonSlug)}`;
52
52
  }
53
53
 
54
54
  private get reasonSlug(): string {
55
55
  if (this.details && typeof this.details === "object") {
56
- // @cast-boundary error-details — KumikoError.details ist per-error
57
- // typed, hier reines reflection-shape für reasonSlug-Lookup.
56
+ // @cast-boundary error-details — per-error typed details; reflection
57
+ // shape only for the reasonSlug lookup.
58
58
  const r = (this.details as Record<string, unknown>)["reason"];
59
- if (typeof r === "string") return r;
59
+ if (typeof r === "string" && REASON_SLUG_RE.test(r)) return r;
60
60
  }
61
61
  return this.code;
62
62
  }
@@ -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
  }
@@ -7,6 +7,7 @@
7
7
  // blind-index column, so equality lookups keep working.
8
8
 
9
9
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { randomBytes } from "node:crypto";
10
11
  import {
11
12
  resetBlindIndexKeyForTests,
12
13
  resetPiiSubjectKmsForTests,
@@ -18,6 +19,7 @@ import {
18
19
  InMemoryKmsAdapter,
19
20
  isPiiCiphertext,
20
21
  KeyNotFoundError,
22
+ PgKmsAdapter,
21
23
  PII_ERASED_SENTINEL,
22
24
  } from "../../crypto";
23
25
  import { applyEntityEvent } from "../../db/apply-entity-event";
@@ -277,6 +279,62 @@ describe("backfillEventPiiEncryption", () => {
277
279
  expect(real.erasedUnresolvable).toBe(dry.erasedUnresolvable);
278
280
  });
279
281
 
282
+ // The prod bug this regression test guards (fw#2255) only ever manifested
283
+ // against a real subject-keys store (kumiko_subject_keys 20→22 rows during
284
+ // a dry run) — the InMemoryKmsAdapter tests above can't see that, since
285
+ // there is no row store to leak into. Run the same dry-run invariant
286
+ // against PgKmsAdapter on real Postgres and assert on the table itself.
287
+ test("dryRun against PgKmsAdapter mints no row in kumiko_subject_keys (fw#2255)", async () => {
288
+ const c1 = generateId();
289
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
290
+
291
+ const baseUrl = process.env["TEST_DATABASE_URL"];
292
+ if (!baseUrl) throw new Error("Missing required env var: TEST_DATABASE_URL");
293
+ const pgKms = new PgKmsAdapter({
294
+ databaseUrl: baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`),
295
+ platformKek: randomBytes(32).toString("base64"),
296
+ maxConnections: 1,
297
+ });
298
+ const raw = asRawClient(testDb.db);
299
+ try {
300
+ // health() creates kumiko_subject_keys lazily so the counts below see
301
+ // a real (empty) table instead of failing on "relation does not exist".
302
+ await pgKms.health();
303
+ configurePiiSubjectKms(pgKms);
304
+
305
+ const before = (await raw.unsafe(
306
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
307
+ )) as ReadonlyArray<{ n: number }>;
308
+
309
+ const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
310
+ expect(dry.failures).toEqual([]);
311
+ expect(dry.encryptedFields).toBe(1);
312
+
313
+ const afterDry = (await raw.unsafe(
314
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
315
+ )) as ReadonlyArray<{ n: number }>;
316
+ expect(afterDry[0]?.n).toBe(before[0]?.n);
317
+ await expect(
318
+ pgKms.getKey({ kind: "user", userId: c1 }, { requestId: "backfill-pii-test" }),
319
+ ).rejects.toThrow(KeyNotFoundError);
320
+
321
+ // The real run does mint exactly one row through the same Pg path —
322
+ // proves the invariant above is a genuine "dry run creates nothing",
323
+ // not an adapter that never creates rows at all.
324
+ const real = await backfillEventPiiEncryption(testDb.db, registry);
325
+ expect(real.encryptedFields).toBe(1);
326
+ const afterReal = (await raw.unsafe(
327
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
328
+ )) as ReadonlyArray<{ n: number }>;
329
+ expect(afterReal[0]?.n).toBe((before[0]?.n ?? 0) + 1);
330
+ await expect(
331
+ pgKms.getKey({ kind: "user", userId: c1 }, { requestId: "backfill-pii-test" }),
332
+ ).resolves.toBeInstanceOf(Buffer);
333
+ } finally {
334
+ await pgKms.close();
335
+ }
336
+ });
337
+
280
338
  test("dryRun on a KMS-era-erased subject (no *.forgotten event) predicts [[erased]] without touching the key store", async () => {
281
339
  const author = generateId();
282
340
  const noteId = generateId();
@@ -13,7 +13,7 @@
13
13
  // are the ceiling.
14
14
  //
15
15
  // Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
16
- // #1940) — see that job's comment in ci.yml for why the gate is p95 not p99.
16
+ // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for checkpoint/fsync spikes.
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -103,6 +103,8 @@ describe("event-store performance — Gate A", () => {
103
103
  );
104
104
 
105
105
  expect(p95).toBeLessThan(30);
106
+ // Tail budget: cold-checkpoint/fsync spikes after warm-up, not connection warm-up.
107
+ expect(p99).toBeLessThan(100);
106
108
  });
107
109
 
108
110
  test("read-latency p95 < 25ms for loadAggregate detail reads", async () => {
@@ -143,6 +145,7 @@ describe("event-store performance — Gate A", () => {
143
145
  // 25ms budget kept from the original spike doc's 10ms — an
144
146
  // order-of-magnitude gate, not an idle-best-case one. Tracking: #325.
145
147
  expect(p95).toBeLessThan(25);
148
+ expect(p99).toBeLessThan(100);
146
149
  });
147
150
 
148
151
  test("update-latency p95 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
@@ -202,6 +205,7 @@ describe("event-store performance — Gate A", () => {
202
205
  );
203
206
 
204
207
  expect(p95).toBeLessThan(30);
208
+ expect(p99).toBeLessThan(100);
205
209
  });
206
210
 
207
211
  test("snapshot-load < 50ms for 1000-event aggregate (Gate A)", async () => {
@@ -51,6 +51,7 @@ describe("unscoped stream primitives — caller allowlist", () => {
51
51
  // Positive control — proves the scan actually ran and found the known
52
52
  // caller, not just that it (silently) found nothing.
53
53
  expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
54
+ expect(matches.has("packages/framework/src/event-store/event-store.ts")).toBe(true);
54
55
 
55
56
  const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
56
57
  expect(offenders).toEqual([]);
@@ -279,6 +279,13 @@ export async function getUnscopedAggregateStreamMaxVersion(
279
279
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
280
280
  // (boot, fresh tenant, post-archive).
281
281
  // @wrapper-known semantic-alias
282
+ /** Seed/orphan helper — prefer this over importing the restricted existence-oracle by name. */
283
+ export async function getUnscopedStreamMaxVersionForSeed(
284
+ ...args: Parameters<typeof getUnscopedAggregateStreamMaxVersion>
285
+ ): ReturnType<typeof getUnscopedAggregateStreamMaxVersion> {
286
+ return getUnscopedAggregateStreamMaxVersion(...args);
287
+ }
288
+
282
289
  export async function getEventsHighWaterMark(db: DbRunner): Promise<bigint> {
283
290
  return selectEventsHighWaterMark(db);
284
291
  }