@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
@@ -1,8 +1,28 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { buildConfigFeatureSchema } from "../../build-config-feature-schema";
2
3
  import { access, createTenantConfig } from "../../config-helpers";
3
4
  import { defineFeature } from "../../define-feature";
5
+ import { createRegistry } from "../../registry";
6
+ import { isFieldsEditSection } from "../../screen-helpers";
7
+ import type { ConfigEditScreenDefinition } from "../../types";
4
8
  import { validateBoot } from "../index";
5
9
 
10
+ // Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
11
+ // labels every masked config key's generated hub requires regardless of
12
+ // which feature owns the key or which scope its keys are visible at
13
+ // (requiredKeysFromGeneratedConfigHub adds all four unconditionally once
14
+ // any masked key exists, see i18n-keys.ts:35-38).
15
+ const configHub = defineFeature("config", (r) => {
16
+ r.translations({
17
+ keys: {
18
+ "config.settings.title": { en: "Settings" },
19
+ "config.settings.system": { en: "Platform" },
20
+ "config.settings.tenant": { en: "Tenant" },
21
+ "config.settings.user": { en: "Personal" },
22
+ },
23
+ });
24
+ });
25
+
6
26
  // fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
7
27
  // masked config key's generated nav entry / configEdit section with the
8
28
  // dot-form key `${feature}.settings` — never colon-form. isI18nKey's
@@ -10,20 +30,6 @@ import { validateBoot } from "../index";
10
30
  // feature could ship a generated Settings screen whose label was never
11
31
  // translated and boot validation stayed silent.
12
32
  describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
13
- // Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
14
- // labels every masked config key's generated hub requires regardless of
15
- // which feature owns the key.
16
- const configHub = defineFeature("config", (r) => {
17
- r.translations({
18
- keys: {
19
- "config.settings.title": { en: "Settings" },
20
- "config.settings.system": { en: "Platform" },
21
- "config.settings.tenant": { en: "Organization" },
22
- "config.settings.user": { en: "Personal" },
23
- },
24
- });
25
- });
26
-
27
33
  function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
28
34
  return defineFeature("billing", (r) => {
29
35
  r.config({
@@ -61,4 +67,131 @@ describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#
61
67
  });
62
68
  expect(() => validateBoot([configHub, billing])).not.toThrow();
63
69
  });
70
+
71
+ test("group namespace ≠ feature name: blank translation key satisfies hub label", () => {
72
+ const billing = defineFeature("billing", (r) => {
73
+ r.config({
74
+ keys: {
75
+ apiKey: createTenantConfig("text", {
76
+ write: access.roles("TenantAdmin"),
77
+ group: "tenant-settings",
78
+ mask: { title: "billing.api-key" },
79
+ }),
80
+ },
81
+ });
82
+ r.translations({
83
+ keys: {
84
+ "billing.api-key": { en: "API Key" },
85
+ "screen:tenant-settings-tenant.title": { en: "Tenant Settings" },
86
+ "tenant-settings.settings": { en: "Tenant Settings" },
87
+ },
88
+ });
89
+ });
90
+ expect(() => validateBoot([configHub, billing])).not.toThrow();
91
+ });
92
+ });
93
+
94
+ // PR #2314 idx2: buildConfigFeatureSchema varies the generated dot-form label
95
+ // along two more paths that fw#2260's tests above never exercise — a tenant
96
+ // key with an elevated (SystemAdmin) write role surfaces the same feature
97
+ // under a second, broader scope (build-config-feature-schema.ts:142-158), and
98
+ // a feature can opt into a configEdit section description
99
+ // (build-config-feature-schema.ts:246-249). The `group`-namespace path is
100
+ // deliberately left untested here — fw#2314 idx1 tracks it as broken
101
+ // (treatDotFormAsKey requires an unsatisfiable `${group}.settings` key for a
102
+ // namespace that has no matching feature), and a test would either fail on
103
+ // the open bug or paper over it; it belongs with the idx1 fix, not this batch.
104
+ describe("validateI18nSurfaceKeys — scoped label fallback for elevated write roles (PR #2314 idx2)", () => {
105
+ function opsFeature(translationKeys: Record<string, { readonly en: string }>) {
106
+ return defineFeature("ops", (r) => {
107
+ r.config({
108
+ keys: {
109
+ // Home scope tenant + an elevated SystemAdmin write role surfaces
110
+ // "ops" under BOTH the tenant audience (home, full write set) and
111
+ // the system audience (cascade, write set ∩ ELEVATED_ROLES.system)
112
+ // — two navs/screens for one feature.
113
+ maintenanceMode: createTenantConfig("boolean", {
114
+ write: access.roles("TenantAdmin", "SystemAdmin"),
115
+ mask: { title: "ops.maintenance-mode" },
116
+ }),
117
+ },
118
+ });
119
+ if (Object.keys(translationKeys).length > 0) {
120
+ r.translations({ keys: translationKeys });
121
+ }
122
+ });
123
+ }
124
+
125
+ test("elevated write role surfaces the feature at two scopes; the scoped override labels the system nav while the tenant nav falls back to the plain key", () => {
126
+ const translations = {
127
+ "ops.maintenance-mode": { en: "Maintenance Mode" },
128
+ "screen:ops-tenant.title": { en: "Ops Settings" },
129
+ "screen:ops-system.title": { en: "Ops Settings (Platform)" },
130
+ // Required unconditionally: both screens' section title is always
131
+ // `${feature}.settings` (build-config-feature-schema.ts:248).
132
+ "ops.settings": { en: "Ops" },
133
+ // Opt-in scoped override — only the system (cascade) nav uses it.
134
+ "ops.settings.system": { en: "Ops (Platform Cascade)" },
135
+ };
136
+ const ops = opsFeature(translations);
137
+
138
+ const schema = buildConfigFeatureSchema(createRegistry([configHub, ops]));
139
+ expect(schema.navs.find((n) => n.id === "ops-system")?.label).toBe("ops.settings.system");
140
+ expect(schema.navs.find((n) => n.id === "ops-tenant")?.label).toBe("ops.settings");
141
+
142
+ expect(() => validateBoot([configHub, ops])).not.toThrow();
143
+ });
144
+ });
145
+
146
+ // isI18nKeys.ts's requiredKeysFromScreen never reads EditFieldsSection.description
147
+ // (only title + field labels, see required-surface-keys.ts:210-224), so this
148
+ // gate is invisible to validateBoot either way — a validateBoot(...).not.toThrow()
149
+ // assertion alone would pass identically with build-config-feature-schema.ts:249
150
+ // deleted outright. Assert on the generated schema itself instead, the only
151
+ // artifact the gate actually changes.
152
+ describe("validateI18nSurfaceKeys — gated configEdit section description (PR #2314 idx2)", () => {
153
+ function reportingFeature(translationKeys: Record<string, { readonly en: string }>) {
154
+ return defineFeature("reporting", (r) => {
155
+ r.config({
156
+ keys: {
157
+ retentionDays: createTenantConfig("number", {
158
+ write: access.roles("TenantAdmin"),
159
+ mask: { title: "reporting.retention-days" },
160
+ }),
161
+ },
162
+ });
163
+ r.translations({ keys: translationKeys });
164
+ });
165
+ }
166
+
167
+ function reportingSection(translationKeys: Record<string, { readonly en: string }>) {
168
+ const reporting = reportingFeature(translationKeys);
169
+ const schema = buildConfigFeatureSchema(createRegistry([configHub, reporting]));
170
+ const screen = schema.screens.find(
171
+ (s): s is ConfigEditScreenDefinition => s.type === "configEdit",
172
+ );
173
+ if (screen === undefined) throw new Error("expected a generated configEdit screen");
174
+ const section = screen.layout.sections[0];
175
+ if (section === undefined || !isFieldsEditSection(section)) {
176
+ throw new Error("expected a fields section");
177
+ }
178
+ return section;
179
+ }
180
+
181
+ test("declaring 'reporting.settings.description' adds it to the generated section; omitting it leaves the section without one", () => {
182
+ const withDescription = reportingSection({
183
+ "reporting.retention-days": { en: "Retention (days)" },
184
+ "screen:reporting-tenant.title": { en: "Reporting Settings" },
185
+ "reporting.settings": { en: "Reporting" },
186
+ "reporting.settings.description": { en: "Configure how long reports are kept." },
187
+ });
188
+ expect(withDescription.description).toBe("reporting.settings.description");
189
+
190
+ const withoutDescription = reportingSection({
191
+ "reporting.retention-days": { en: "Retention (days)" },
192
+ "screen:reporting-tenant.title": { en: "Reporting Settings" },
193
+ "reporting.settings": { en: "Reporting" },
194
+ });
195
+ expect(withoutDescription.description).toBeUndefined();
196
+ });
64
197
  });
@@ -587,6 +587,11 @@ function validateEmbeddedDerivedCells(
587
587
  `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`,
588
588
  );
589
589
  }
590
+ if (sourceName in field.derived) {
591
+ throw new Error(
592
+ `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`,
593
+ );
594
+ }
590
595
  }
591
596
  }
592
597
  }
@@ -218,20 +218,32 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
218
218
  }
219
219
 
220
220
  if (retention.strategy === "blockDelete") {
221
- // blockDelete on an entity with no subject field is the correct
222
- // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
223
- const hasSubjectField = Object.values(fieldsByName).some(
221
+ // blockDelete with no field that can carry `anonymize` is fine;
222
+ // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
223
+ // instead (#1622, #2336).
224
+ const entityHasAnonymizableSubjectField = Object.values(fieldsByName).some(
224
225
  (f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
225
226
  );
226
227
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
227
228
  const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
228
229
  return Boolean(a.anonymize);
229
230
  });
230
- if (hasSubjectField && !hasAnonymize) {
231
+ if (entityHasAnonymizableSubjectField && !hasAnonymize) {
231
232
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
232
233
  console.warn(
233
234
  `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
234
235
  );
236
+ } else if (!entityHasAnonymizableSubjectField) {
237
+ const hasSubjectRef = Object.values(fieldsByName).some((f) => {
238
+ const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
239
+ return Boolean(a.subjectRef);
240
+ });
241
+ if (hasSubjectRef) {
242
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
243
+ console.warn(
244
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`,
245
+ );
246
+ }
235
247
  }
236
248
  }
237
249
  }
@@ -20,7 +20,7 @@ import type {
20
20
  EditLayout,
21
21
  FieldCondition,
22
22
  RowAction,
23
- RowActionNavigate,
23
+ RowActionNavigateBase,
24
24
  RowFieldExtractor,
25
25
  ScreenDefinition,
26
26
  ToolbarAction,
@@ -345,12 +345,19 @@ function validateToolbarDrawerAction(
345
345
  // call sites so the mutual-exclusivity check and the entity→detailFor
346
346
  // resolution don't drift between them (same drift risk
347
347
  // validateRowActionNavigateParams above is already shared to avoid).
348
+ // Runtime-loose shape: boot still rejects both/neither for untyped schemas;
349
+ // authors get the exclusive union via RowActionNavigate (#2303).
350
+ type RowActionNavigateRuntime = RowActionNavigateBase & {
351
+ readonly screen?: string;
352
+ readonly entity?: string;
353
+ };
354
+
348
355
  function resolveRowActionNavigateTarget(
349
356
  featureName: string,
350
357
  screenId: string,
351
358
  screenType: "entityList" | "projectionList" | "projectionDetail",
352
359
  actionLabel: "rowAction" | "action",
353
- action: RowActionNavigate,
360
+ action: RowActionNavigateRuntime,
354
361
  allScreenQns: ReadonlySet<string>,
355
362
  navTargetShortIds: ReadonlySet<string>,
356
363
  screensByShortId: ReadonlyMap<
@@ -1,13 +1,11 @@
1
1
  import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types";
2
2
 
3
3
  /** Computes a derived cell from its source values. Missing/non-numeric
4
- * sources are treated as 0 for "sum"/"subtract"; "multiply" with any
5
- * missing source returns undefined (an incomplete product isn't a
6
- * meaningful partial value). Money cells are minor-unit integers — this
7
- * function is unit-agnostic, it just does arithmetic on whatever numbers
8
- * it's given (caller passes minor units for money, not major/float).
9
- * `withDerivedCells` rounds the result to the target sub-field's declared
10
- * precision afterward. */
4
+ * sources: "multiply" with any missing source → undefined; "sum"/"subtract"
5
+ * treat missing as 0 only when at least one source is present — if every
6
+ * source is missing, return undefined (do not invent 0). Money cells are
7
+ * minor-unit integers unit-agnostic arithmetic; `withDerivedCells` rounds
8
+ * to the target sub-field's declared precision afterward. */
11
9
  export function computeDerivedCellValue(
12
10
  op: EmbeddedDerivedCellDef["op"],
13
11
  values: readonly (number | undefined)[],
@@ -16,6 +14,7 @@ export function computeDerivedCellValue(
16
14
  if (values.some((value) => value === undefined)) return undefined;
17
15
  return (values as readonly number[]).reduce((product, value) => product * value, 1);
18
16
  }
17
+ if (values.every((value) => value === undefined)) return undefined;
19
18
  const numeric = values.map((value) => value ?? 0);
20
19
  if (op === "sum") return numeric.reduce((sum, value) => sum + value, 0);
21
20
  // subtract: first value minus every subsequent value.
@@ -60,8 +59,8 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
60
59
  /** Recomputes every derived cell of an embedded-list row from its raw
61
60
  * values, overwriting whatever the client sent instead of merely checking
62
61
  * it — the server is the authority for derived cells. Reads source values
63
- * from the original row (never from an already-recomputed derived cell),
64
- * so the iteration order of `derived` never matters. A row that isn't a
62
+ * from the working copy so already-recomputed sources feed later cells;
63
+ * boot-validator rejects derived-from-derived chains. A row that isn't a
65
64
  * plain object (already invalid, or not this field's shape) passes through
66
65
  * untouched — validation downstream rejects it. The computed value is
67
66
  * rounded to the target sub-field's declared precision (`schema`) before
@@ -77,11 +76,13 @@ export function withDerivedCells(
77
76
  const copy: Record<string, unknown> = { ...source };
78
77
  for (const [cellName, def] of Object.entries(derived)) {
79
78
  const sourceValues = def.from.map((sourceField) => {
80
- const value = source[sourceField];
79
+ const value = copy[sourceField];
81
80
  return typeof value === "number" ? value : undefined;
82
81
  });
83
82
  const computed = computeDerivedCellValue(def.op, sourceValues);
84
83
  if (computed === undefined) {
84
+ // Always clear — keeping a client value would let callers spoof required
85
+ // derived cells when sources are missing (server must stay authoritative).
85
86
  delete copy[cellName];
86
87
  } else {
87
88
  const target = schema[cellName];
@@ -0,0 +1,28 @@
1
+ // Hook signature types for EXT_STORAGE_PROVIDER (tenant-destroy binary cleanup).
2
+ //
3
+ // Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
4
+ // rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
5
+ // passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
6
+ // extension point, not just this one. ctx here only guarantees tenantId plus
7
+ // the optional fileProviderResolver/log; the richer stage-runner ctx
8
+ // (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
9
+ // typed against this minimal ctx is safely assignable wherever that richer ctx
10
+ // is passed.
11
+
12
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
13
+ import type { TenantId } from "../types";
14
+
15
+ export interface StorageProviderHookCtx {
16
+ readonly tenantId: TenantId;
17
+ readonly fileProviderResolver?: FileProviderResolver;
18
+ readonly log?: (message: string) => void;
19
+ }
20
+
21
+ export type StorageProviderDestroyTenantHook = (
22
+ tenantId: TenantId,
23
+ ctx: StorageProviderHookCtx,
24
+ ) => Promise<void>;
25
+
26
+ export interface StorageProviderExtensionHooks {
27
+ readonly destroyTenant: StorageProviderDestroyTenantHook;
28
+ }
@@ -66,6 +66,10 @@ export type TenantUserModel = "single-user" | "multi-user";
66
66
  */
67
67
  export interface UserDataStorageProvider {
68
68
  delete(storageKey: string): Promise<void>;
69
+ // Needed so a forget/tenant-destroy hook can find derived/variant keys
70
+ // (thumbnails, resized variants) that are never tracked anywhere but the
71
+ // storage layer itself — see fileRefDeleteHook.
72
+ list(prefix: string): Promise<readonly string[]>;
69
73
  }
70
74
 
71
75
  export interface UserDataHookCtx {
@@ -2588,7 +2588,7 @@ describe("cross-file registrar-wrapper resolution against a real filesystem Proj
2588
2588
 
2589
2589
  test("resolves the imported wrapper and recognises the nav pattern it registers", () => {
2590
2590
  expect(result.errors).toEqual([]);
2591
- expect(result.patterns.map((p) => p.kind)).toEqual(["nav", "requires"]);
2591
+ expect(result.patterns.map((p) => p.kind)).toEqual(["requires", "nav"]);
2592
2592
  });
2593
2593
 
2594
2594
  test("the resolved pattern's source points at the imported file, not the entry file", () => {
@@ -391,6 +391,16 @@ describe("addPattern — error paths", () => {
391
391
  const sf = makeSourceFile("export const x = 1;");
392
392
  expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
393
393
  });
394
+
395
+ test("throws for ai.* kinds (must target a workflow steps array)", () => {
396
+ const sf = makeSourceFile(`
397
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
398
+ defineFeature("ai-host", (r) => {});
399
+ `);
400
+ expect(() =>
401
+ addPattern(sf, { kind: "ai.generate", stepKey: "generate" } as FeaturePattern),
402
+ ).toThrow(/ai\.generate steps must be inserted into a defineWorkflow/);
403
+ });
394
404
  });
395
405
 
396
406
  describe("callMatchesId — positional and object-form lookups", () => {
@@ -869,9 +869,7 @@ describe("render → parse roundtrip — AI pipeline steps", () => {
869
869
 
870
870
  test("inline + const-ref ai steps round-trip structurally", () => {
871
871
  const aiPatterns = initial.patterns.filter((p) => p.kind.startsWith("ai."));
872
- const stepCalls = aiPatterns
873
- .map((p) => indent(renderPattern(p), " ").replace(/;\s*$/, ","))
874
- .join("\n");
872
+ const stepCalls = aiPatterns.map((p) => `${indent(renderPattern(p), " ")},`).join("\n");
875
873
  const wrapped = `
876
874
  import { defineFeature, defineWorkflow, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
877
875
  import { z } from "zod";
@@ -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;