@cosmicdrift/kumiko-framework 0.335.0 → 0.336.1

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 (49) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/index.d.ts +2 -1
  4. package/dist/api/index.js +1 -0
  5. package/dist/api/routes.js +1 -1
  6. package/dist/api/server.js +6 -3
  7. package/dist/api/sse-route.d.ts +4 -1
  8. package/dist/api/sse-route.js +38 -11
  9. package/dist/crypto/event-pii.js +3 -3
  10. package/dist/db/blind-index-cleanup.js +27 -16
  11. package/dist/db/event-store-executor-read.js +6 -2
  12. package/dist/engine/boot-validator/access-declarations.js +2 -1
  13. package/dist/engine/boot-validator/query-output-columns.js +12 -0
  14. package/dist/engine/boot-validator/screens.js +22 -13
  15. package/dist/engine/build-app-schema.js +5 -2
  16. package/dist/engine/create-app.js +10 -1
  17. package/dist/engine/define-handler.js +3 -5
  18. package/dist/engine/extension-names.d.ts +4 -0
  19. package/dist/engine/extension-names.js +4 -0
  20. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  21. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  22. package/dist/engine/feature-entity-handlers.js +2 -0
  23. package/dist/engine/registry-validate.d.ts +1 -0
  24. package/dist/engine/registry-validate.js +14 -0
  25. package/dist/engine/registry.js +2 -1
  26. package/dist/engine/steps/_event-match.js +12 -4
  27. package/dist/engine/system-user.d.ts +1 -0
  28. package/dist/engine/system-user.js +3 -0
  29. package/dist/env/dry-run.js +10 -3
  30. package/dist/event-store/event-store.js +4 -1
  31. package/dist/jobs/job-runner.js +29 -7
  32. package/dist/migrations/pending-rebuilds.js +4 -3
  33. package/dist/observability/index.d.ts +1 -1
  34. package/dist/observability/index.js +1 -1
  35. package/dist/observability/standard-metrics.d.ts +4 -0
  36. package/dist/observability/standard-metrics.js +16 -0
  37. package/dist/pipeline/dispatch-batch.js +2 -2
  38. package/dist/pipeline/dispatcher.js +1 -1
  39. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  40. package/dist/pipeline/event-dispatcher.js +4 -1
  41. package/dist/pipeline/system-hooks.js +10 -13
  42. package/dist/pipeline/write-origin.js +13 -6
  43. package/dist/stack/drain-event-consumers.js +3 -0
  44. package/dist/stack/drain-jobs.d.ts +3 -1
  45. package/dist/stack/drain-jobs.js +31 -1
  46. package/package.json +4 -4
  47. package/src/changes.json +64 -0
  48. package/src/scripts/codemod/pii-personal-migration.ts +65 -45
  49. package/src/scripts/codemod/unprocessable-error-details-reason.ts +61 -24
@@ -21,7 +21,9 @@
21
21
  // packages/types/src/fields.ts). Never guesses: anything outside this
22
22
  // table is reported (file:line) instead of transformed.
23
23
  //
24
- // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
24
+ // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run | --report-stance]
25
+ // --report-stance is read-only: it writes nothing and classifies fields
26
+ // that carry no personal stance.
25
27
 
26
28
  import { readFileSync } from "node:fs";
27
29
  import { relative, resolve } from "node:path";
@@ -423,14 +425,12 @@ function applyTransform(
423
425
  obj.insertPropertyAssignments(insertIndex, newProps);
424
426
  }
425
427
 
426
- function findTargetFiles(rootDir: string): string[] {
428
+ function findSourceFiles(rootDir: string, extraExcludes: readonly string[] = []): string[] {
427
429
  const glob = new Glob("**/*.{ts,tsx}");
428
- // engine/factories.ts constructs raw ResolvedPiiFlags literals as the
429
- // *implementation* of expandPersonalAnnotations (personal -> flags) —
430
- // not an authored override, so it's not a migration target.
431
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/", "/engine/factories.ts"];
430
+ const EXCLUDE = ["/node_modules/", "/dist/", "/build/", ...extraExcludes];
432
431
  const files: string[] = [];
433
432
  for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
433
+ if (file.endsWith(".d.ts")) continue;
434
434
  const abs = resolve(rootDir, file);
435
435
  if (EXCLUDE.some((p) => abs.includes(p))) continue;
436
436
  files.push(abs);
@@ -440,6 +440,8 @@ function findTargetFiles(rootDir: string): string[] {
440
440
 
441
441
  export type StanceClass = "direct" | "user-owned" | "user-reference" | "near-miss" | "unclassified";
442
442
 
443
+ export type HintCategory = "direct" | "user-owned" | "user-reference";
444
+
443
445
  export type StanceSite = {
444
446
  readonly line: number;
445
447
  readonly field: string;
@@ -447,14 +449,23 @@ export type StanceSite = {
447
449
  readonly callee: string;
448
450
  readonly stance: StanceClass;
449
451
  readonly hint: string | undefined;
452
+ readonly nearMissOf: HintCategory | undefined;
450
453
  };
451
454
 
452
- const ALL_PII_NAME_HINTS: readonly string[] = [
453
- ...PII_DIRECT_NAME_HINTS,
454
- ...PII_USER_OWNED_NAME_HINTS,
455
- ...PII_USER_REFERENCE_NAME_HINTS,
455
+ type CategorizedHint = readonly [hint: string, category: HintCategory];
456
+
457
+ const ALL_PII_NAME_HINTS: readonly CategorizedHint[] = [
458
+ ...[...PII_DIRECT_NAME_HINTS].map((hint): CategorizedHint => [hint, "direct"]),
459
+ ...[...PII_USER_OWNED_NAME_HINTS].map((hint): CategorizedHint => [hint, "user-owned"]),
460
+ ...[...PII_USER_REFERENCE_NAME_HINTS].map((hint): CategorizedHint => [hint, "user-reference"]),
456
461
  ];
457
462
 
463
+ type FieldClassification = {
464
+ readonly stance: StanceClass;
465
+ readonly hint: string | undefined;
466
+ readonly nearMissOf: HintCategory | undefined;
467
+ };
468
+
458
469
  const REPORT_STANCE_CALLEES = new Set(["createTextField", "createLongTextField"]);
459
470
 
460
471
  // Mirrors guard-text-field-stance.ts's hasPersonalStance exactly.
@@ -539,11 +550,15 @@ function hintOccursAtBoundary(fieldLower: string, fieldOriginal: string, hint: s
539
550
  }
540
551
  }
541
552
 
542
- function findLongestBoundaryHint(fieldLower: string, fieldOriginal: string): string | undefined {
543
- let best: string | undefined;
544
- for (const hint of ALL_PII_NAME_HINTS) {
545
- if (best && hint.length <= best.length) continue;
546
- if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint)) best = hint;
553
+ function findLongestBoundaryHint(
554
+ fieldLower: string,
555
+ fieldOriginal: string,
556
+ ): CategorizedHint | undefined {
557
+ let best: CategorizedHint | undefined;
558
+ for (const entry of ALL_PII_NAME_HINTS) {
559
+ const [hint] = entry;
560
+ if (best && hint.length <= best[0].length) continue;
561
+ if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint)) best = entry;
547
562
  }
548
563
  return best;
549
564
  }
@@ -564,29 +579,36 @@ function segmentAlignedSuffixes(fieldOriginal: string): string[] {
564
579
  // (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
565
580
  // The suffix must be the tail of the hint: plain `includes` matched "number"
566
581
  // inside "phonenumber" for "orderNumber".
567
- function findShortestHintContainingSuffix(fieldOriginal: string): string | undefined {
568
- let best: string | undefined;
582
+ function findShortestHintContainingSuffix(fieldOriginal: string): CategorizedHint | undefined {
583
+ let best: CategorizedHint | undefined;
569
584
  for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
570
585
  if (suffix.length < 5) continue;
571
- for (const hint of ALL_PII_NAME_HINTS) {
586
+ for (const entry of ALL_PII_NAME_HINTS) {
587
+ const [hint] = entry;
572
588
  if (hint === suffix || !hint.endsWith(suffix)) continue;
573
- if (!best || hint.length < best.length) best = hint;
589
+ if (!best || hint.length < best[0].length) best = entry;
574
590
  }
575
591
  }
576
592
  return best;
577
593
  }
578
594
 
579
- function classifyFieldStance(field: string): { stance: StanceClass; hint: string | undefined } {
595
+ function classifyFieldStance(field: string): FieldClassification {
580
596
  const fieldLower = field.replaceAll("_", "").toLowerCase();
581
- if (PII_DIRECT_NAME_HINTS.has(fieldLower)) return { stance: "direct", hint: fieldLower };
582
- if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) return { stance: "user-owned", hint: fieldLower };
583
- if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower))
584
- return { stance: "user-reference", hint: fieldLower };
585
- const containmentHint = findLongestBoundaryHint(field.toLowerCase(), field);
586
- if (containmentHint) return { stance: "near-miss", hint: containmentHint };
587
- const suffixHint = findShortestHintContainingSuffix(field);
588
- if (suffixHint) return { stance: "near-miss", hint: suffixHint };
589
- return { stance: "unclassified", hint: undefined };
597
+ if (PII_DIRECT_NAME_HINTS.has(fieldLower)) {
598
+ return { stance: "direct", hint: fieldLower, nearMissOf: undefined };
599
+ }
600
+ if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) {
601
+ return { stance: "user-owned", hint: fieldLower, nearMissOf: undefined };
602
+ }
603
+ if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower)) {
604
+ return { stance: "user-reference", hint: fieldLower, nearMissOf: undefined };
605
+ }
606
+ const nearMiss =
607
+ findLongestBoundaryHint(field.toLowerCase(), field) ?? findShortestHintContainingSuffix(field);
608
+ if (nearMiss) {
609
+ return { stance: "near-miss", hint: nearMiss[0], nearMissOf: nearMiss[1] };
610
+ }
611
+ return { stance: "unclassified", hint: undefined, nearMissOf: undefined };
590
612
  }
591
613
 
592
614
  function createReportStanceProject(): Project {
@@ -627,9 +649,9 @@ function collectStanceSites(sourceFile: SourceFile): StanceSite[] {
627
649
  // A call with no enclosing field (e.g. a bare createTextField() at top
628
650
  // level) has no real name to classify — `createTextField(...)` would
629
651
  // otherwise false-positive as a near-miss on its own "Text".
630
- const { stance, hint } = enclosingField
652
+ const { stance, hint, nearMissOf }: FieldClassification = enclosingField
631
653
  ? classifyFieldStance(enclosingField)
632
- : { stance: "unclassified" as const, hint: undefined };
654
+ : { stance: "unclassified", hint: undefined, nearMissOf: undefined };
633
655
 
634
656
  sites.push({
635
657
  line: call.getStartLineNumber(),
@@ -638,22 +660,17 @@ function collectStanceSites(sourceFile: SourceFile): StanceSite[] {
638
660
  callee,
639
661
  stance,
640
662
  hint,
663
+ nearMissOf,
641
664
  });
642
665
  }
643
666
  return sites;
644
667
  }
645
668
 
646
- function findReportStanceFiles(rootDir: string): string[] {
647
- const glob = new Glob("**/*.{ts,tsx}");
648
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
649
- const files: string[] = [];
650
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
651
- if (file.endsWith(".d.ts")) continue;
652
- const abs = resolve(rootDir, file);
653
- if (EXCLUDE.some((p) => abs.includes(p))) continue;
654
- files.push(abs);
655
- }
656
- return files.sort();
669
+ function formatHintSuffix(site: StanceSite): string {
670
+ if (!site.hint) return "";
671
+ return site.nearMissOf
672
+ ? ` (hint: ${site.hint}, near ${site.nearMissOf})`
673
+ : ` (hint: ${site.hint})`;
657
674
  }
658
675
 
659
676
  function reportStance(rootDir: string): void {
@@ -671,7 +688,7 @@ function reportStance(rootDir: string): void {
671
688
  let total = 0;
672
689
 
673
690
  const project = createReportStanceProject();
674
- for (const file of findReportStanceFiles(rootDir)) {
691
+ for (const file of findSourceFiles(rootDir)) {
675
692
  const sites = reportStanceForSource(readFileSync(file, "utf8"), file, project);
676
693
  if (sites.length === 0) continue;
677
694
 
@@ -680,7 +697,7 @@ function reportStance(rootDir: string): void {
680
697
  totals[site.stance]++;
681
698
  total++;
682
699
  const entity = site.entity ?? "<unresolved>";
683
- const hintSuffix = site.hint ? ` (hint: ${site.hint})` : "";
700
+ const hintSuffix = formatHintSuffix(site);
684
701
  console.log(
685
702
  ` ${site.line} ${site.field} entity=${entity} ${site.callee} ${site.stance}${hintSuffix}`,
686
703
  );
@@ -711,7 +728,10 @@ async function main(): Promise<void> {
711
728
  }
712
729
 
713
730
  const dryRun = process.argv.includes("--dry-run");
714
- const files = findTargetFiles(rootDir);
731
+ // engine/factories.ts constructs raw ResolvedPiiFlags literals as the
732
+ // *implementation* of expandPersonalAnnotations (personal -> flags) —
733
+ // not an authored override, so it's not a migration target.
734
+ const files = findSourceFiles(rootDir, ["/engine/factories.ts"]);
715
735
  const project = new Project({
716
736
  skipAddingFilesFromTsConfig: true,
717
737
  skipFileDependencyResolution: true,
@@ -105,9 +105,25 @@ function resolveDetailsProperty(optsLiteral: ObjectLiteralExpression): DetailsRe
105
105
  /** True if removing this shorthand-bound identifier would leave its declaration with no other use. */
106
106
  function wouldLeaveUnusedVariable(nameNode: Identifier): boolean {
107
107
  try {
108
- const refs = nameNode.findReferencesAsNodes();
109
- const others = refs.filter((r) => r !== nameNode);
110
- return others.length === 0;
108
+ // The shorthand name resolves to the object-literal property symbol too, and
109
+ // a declaration counts as a reference, so look up the variable's own uses.
110
+ const shorthand = nameNode.getParent();
111
+ const variableSymbol = Node.isShorthandPropertyAssignment(shorthand)
112
+ ? shorthand.getValueSymbol()
113
+ : undefined;
114
+ const declarations = variableSymbol?.getDeclarations() ?? [];
115
+ const declarationNames = declarations.flatMap((declaration) =>
116
+ Node.isVariableDeclaration(declaration) || Node.isParameterDeclaration(declaration)
117
+ ? [declaration.getNameNode()]
118
+ : [],
119
+ );
120
+ // No resolvable declaration: can't verify, treat as unsafe.
121
+ if (declarationNames.length === 0) return true;
122
+ return declarationNames
123
+ .flatMap((declarationName) =>
124
+ Node.isIdentifier(declarationName) ? declarationName.findReferencesAsNodes() : [],
125
+ )
126
+ .every((usage) => usage === nameNode);
111
127
  } catch {
112
128
  // Can't verify — treat as unsafe rather than guess.
113
129
  return true;
@@ -122,12 +138,11 @@ type RemovalResult =
122
138
  /** Removes the `reason` property from a `details` object literal, in place. */
123
139
  function removeReasonProperty(detailsLiteral: ObjectLiteralExpression): RemovalResult {
124
140
  const properties = detailsLiteral.getProperties();
125
- if (properties.some((p) => Node.isSpreadAssignment(p))) {
126
- return {
127
- kind: "skip",
128
- note: "details object literal contains a spread — not statically decidable",
129
- };
130
- }
141
+ const hasSpread = properties.some((p) => Node.isSpreadAssignment(p));
142
+ const spreadSkip: RemovalResult = {
143
+ kind: "skip",
144
+ note: "details object literal has a spread next to an explicit reason — not statically decidable",
145
+ };
131
146
 
132
147
  for (const prop of properties) {
133
148
  if (Node.isPropertyAssignment(prop)) {
@@ -145,12 +160,14 @@ function removeReasonProperty(detailsLiteral: ObjectLiteralExpression): RemovalR
145
160
  ? nameNode.getLiteralText()
146
161
  : undefined;
147
162
  if (propName === "reason") {
163
+ if (hasSpread) return spreadSkip;
148
164
  prop.remove();
149
165
  return { kind: "removed" };
150
166
  }
151
167
  continue;
152
168
  }
153
169
  if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "reason") {
170
+ if (hasSpread) return spreadSkip;
154
171
  if (wouldLeaveUnusedVariable(prop.getNameNode())) {
155
172
  return {
156
173
  kind: "skip",
@@ -166,13 +183,20 @@ function removeReasonProperty(detailsLiteral: ObjectLiteralExpression): RemovalR
166
183
 
167
184
  type CallOutcome = "changed" | "skipped" | "not-a-match";
168
185
 
169
- function processNewExpression(args: readonly Node[], report: (note: string) => void): CallOutcome {
186
+ // "manual": a concrete `reason` the codemod found but cannot remove safely.
187
+ // "unverified": opts/details not statically analyzable — may well be fine, so
188
+ // it is listed apart to keep the actionable list short.
189
+ type SkipKind = "manual" | "unverified";
190
+ type ReportSkip = (note: string, kind: SkipKind) => void;
191
+
192
+ function processNewExpression(args: readonly Node[], report: ReportSkip): CallOutcome {
170
193
  const optsArg = args[1];
171
194
  if (!optsArg) return "not-a-match";
172
195
 
173
196
  if (!Node.isObjectLiteralExpression(optsArg)) {
174
197
  report(
175
198
  "second argument (opts) is not a statically-analyzable object literal — cannot verify details.reason",
199
+ "unverified",
176
200
  );
177
201
  return "skipped";
178
202
  }
@@ -180,14 +204,14 @@ function processNewExpression(args: readonly Node[], report: (note: string) => v
180
204
  const detailsRes = resolveDetailsProperty(optsArg);
181
205
  if (detailsRes.kind === "absent") return "not-a-match";
182
206
  if (detailsRes.kind === "unresolvable") {
183
- report(detailsRes.note);
207
+ report(detailsRes.note, "unverified");
184
208
  return "skipped";
185
209
  }
186
210
 
187
211
  const removal = removeReasonProperty(detailsRes.literal);
188
212
  if (removal.kind === "absent") return "not-a-match";
189
213
  if (removal.kind === "skip") {
190
- report(removal.note);
214
+ report(removal.note, "manual");
191
215
  return "skipped";
192
216
  }
193
217
  return "changed";
@@ -195,16 +219,14 @@ function processNewExpression(args: readonly Node[], report: (note: string) => v
195
219
 
196
220
  // `failUnprocessable(reason, details)` takes the details literal directly as
197
221
  // its second argument (no `{ details }` wrapper like the ctor's opts).
198
- function processFailUnprocessableCall(
199
- args: readonly Node[],
200
- report: (note: string) => void,
201
- ): CallOutcome {
222
+ function processFailUnprocessableCall(args: readonly Node[], report: ReportSkip): CallOutcome {
202
223
  const detailsArg = args[1];
203
224
  if (!detailsArg) return "not-a-match";
204
225
 
205
226
  if (!Node.isObjectLiteralExpression(detailsArg)) {
206
227
  report(
207
228
  "second argument (details) is not a statically-analyzable object literal — cannot verify details.reason",
229
+ "unverified",
208
230
  );
209
231
  return "skipped";
210
232
  }
@@ -212,18 +234,23 @@ function processFailUnprocessableCall(
212
234
  const removal = removeReasonProperty(detailsArg);
213
235
  if (removal.kind === "absent") return "not-a-match";
214
236
  if (removal.kind === "skip") {
215
- report(removal.note);
237
+ report(removal.note, "manual");
216
238
  return "skipped";
217
239
  }
218
240
  return "changed";
219
241
  }
220
242
 
221
- type SkipEntry = { readonly file: string; readonly line: number; readonly note: string };
243
+ type SkipEntry = {
244
+ readonly file: string;
245
+ readonly line: number;
246
+ readonly note: string;
247
+ readonly kind: SkipKind;
248
+ };
222
249
 
223
250
  type MigratableCall = {
224
251
  readonly exportName: "UnprocessableError" | "failUnprocessable";
225
252
  readonly localKind: "class" | "function";
226
- readonly process: (args: readonly Node[], report: (note: string) => void) => CallOutcome;
253
+ readonly process: (args: readonly Node[], report: ReportSkip) => CallOutcome;
227
254
  };
228
255
 
229
256
  const UNPROCESSABLE_ERROR_CTOR: MigratableCall = {
@@ -256,7 +283,7 @@ function migrateCallSite(
256
283
  const note = binding
257
284
  ? `${target.exportName} is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`
258
285
  : `${target.exportName} has no @cosmicdrift/kumiko-* import in this file (likely a locally defined ${target.localKind})`;
259
- skips.push({ file, line, note });
286
+ skips.push({ file, line, note, kind: "manual" });
260
287
  return false;
261
288
  }
262
289
  if (!KUMIKO_MODULE_RE.test(binding.moduleSpecifier)) {
@@ -264,11 +291,14 @@ function migrateCallSite(
264
291
  file,
265
292
  line,
266
293
  note: `${target.exportName} is imported from "${binding.moduleSpecifier}", not a @cosmicdrift/kumiko-* package`,
294
+ kind: "manual",
267
295
  });
268
296
  return false;
269
297
  }
270
298
 
271
- const outcome = target.process(site.getArguments(), (note) => skips.push({ file, line, note }));
299
+ const outcome = target.process(site.getArguments(), (note, kind) =>
300
+ skips.push({ file, line, note, kind }),
301
+ );
272
302
  return outcome === "changed";
273
303
  }
274
304
 
@@ -320,9 +350,16 @@ async function main(): Promise<void> {
320
350
  console.log(
321
351
  `Touched ${touchedFiles} files, removed ${removedTotal} redundant "reason" propert(y/ies).`,
322
352
  );
323
- console.log(`Skipped ${skips.length} site(s):`);
324
- for (const s of skips) {
325
- console.log(` ${relative(rootDir, s.file)}:${s.line} — ${s.note}`);
353
+ for (const kind of ["manual", "unverified"] as const) {
354
+ const ofKind = skips.filter((s) => s.kind === kind);
355
+ console.log(
356
+ kind === "manual"
357
+ ? `Skipped ${ofKind.length} site(s) needing manual work:`
358
+ : `Unverified ${ofKind.length} site(s) (opts/details not statically analyzable, may be fine):`,
359
+ );
360
+ for (const s of ofKind) {
361
+ console.log(` ${relative(rootDir, s.file)}:${s.line} — ${s.note}`);
362
+ }
326
363
  }
327
364
  console.log();
328
365
  }