@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -101,6 +101,46 @@ describe("getAllQueryHandlers", () => {
101
101
  });
102
102
  });
103
103
 
104
+ describe("getAllStreamHandlers", () => {
105
+ test("returns every registered stream handler, qualified, across multiple features", () => {
106
+ const aiFeature = defineFeature("registry-test-ai", (r) => {
107
+ r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
108
+ access: { openToAll: true },
109
+ });
110
+ });
111
+ const otherFeature = defineFeature("registry-test-other", (r) => {
112
+ r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
113
+ access: { openToAll: true },
114
+ });
115
+ });
116
+
117
+ const registry = createRegistry([aiFeature, otherFeature]);
118
+ const handlers = registry.getAllStreamHandlers();
119
+
120
+ expect(handlers.get("registry-test-ai:stream:chat:complete")).toBeDefined();
121
+ expect(handlers.get("registry-test-other:stream:chat:complete")).toBeDefined();
122
+ // Same Map instance getStreamHandler reads from, not a copy that can drift.
123
+ expect(handlers.get("registry-test-ai:stream:chat:complete")).toBe(
124
+ registry.getStreamHandler("registry-test-ai:stream:chat:complete"),
125
+ );
126
+ });
127
+
128
+ test("empty registry returns an empty map, not undefined", () => {
129
+ const registry = createRegistry([]);
130
+ expect(registry.getAllStreamHandlers().size).toBe(0);
131
+ });
132
+
133
+ test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
134
+ const aiFeature = defineFeature("registry-test-dup-a", (r) => {
135
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
136
+ });
137
+ const otherFeature = defineFeature("registry-test-dup-b", (r) => {
138
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
139
+ });
140
+ expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
141
+ });
142
+ });
143
+
104
144
  describe("extensionSelector boot-validation", () => {
105
145
  function foundationFeature() {
106
146
  return defineFeature("probe-foundation", (r) => {
@@ -75,6 +75,18 @@ describe("r.storeTable — declaration", () => {
75
75
  ).toThrow(/requires source: "unmanaged"/);
76
76
  });
77
77
 
78
+ test("rejects a table name with the reserved read_ prefix (#1220)", () => {
79
+ const readPrefixed = defineUnmanagedTable({
80
+ tableName: "read_rt_probe",
81
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
82
+ });
83
+ expect(() =>
84
+ defineFeature("probe", (r) => {
85
+ r.storeTable(readPrefixed, { reason: "test" });
86
+ }),
87
+ ).toThrow(/the "read_" prefix is reserved/);
88
+ });
89
+
78
90
  test("accepts valid registration and stores meta + reason", () => {
79
91
  const feature = defineFeature("probe", (r) => {
80
92
  r.storeTable(probeMeta, {
@@ -5,7 +5,11 @@
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
7
  import { createEntity, defineFeature } from "../index";
8
- import { findTierResolverUsage, TENANT_TIER_RESOLVER_EXT } from "../tier-resolver-extension";
8
+ import {
9
+ findTierResolverUsage,
10
+ isTierResolverPlugin,
11
+ TENANT_TIER_RESOLVER_EXT,
12
+ } from "../tier-resolver-extension";
9
13
 
10
14
  function tierResolverFeature(name: string) {
11
15
  return defineFeature(name, (r) => {
@@ -49,3 +53,17 @@ describe("findTierResolverUsage", () => {
49
53
  expect(findTierResolverUsage([])).toBeUndefined();
50
54
  });
51
55
  });
56
+
57
+ describe("isTierResolverPlugin", () => {
58
+ test("true when build is a function", () => {
59
+ expect(isTierResolverPlugin({ build: async () => new Set() })).toBe(true);
60
+ });
61
+
62
+ test("false for null, non-objects, and missing/non-fn build", () => {
63
+ expect(isTierResolverPlugin(null)).toBe(false);
64
+ expect(isTierResolverPlugin(undefined)).toBe(false);
65
+ expect(isTierResolverPlugin("x")).toBe(false);
66
+ expect(isTierResolverPlugin({})).toBe(false);
67
+ expect(isTierResolverPlugin({ build: 1 })).toBe(false);
68
+ });
69
+ });
@@ -1,3 +1,4 @@
1
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
1
2
  import type {
2
3
  EditFieldSpec,
3
4
  EditLayout,
@@ -6,7 +7,6 @@ import type {
6
7
  RowAction,
7
8
  ToolbarAction,
8
9
  } from "../types";
9
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
10
10
 
11
11
  const FUNCTION_DROPPED_HINT =
12
12
  "functions are dropped by JSON.stringify when the screen config reaches the client " +
@@ -0,0 +1,21 @@
1
+ import type { BootCheckContext, FeatureDefinition } from "../types";
2
+
3
+ // r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
4
+ // relying on framework-internal knowledge (the gdpr-storage.ts guards are
5
+ // the framework-owned version of this same idea). Each registered fn gets
6
+ // the full mounted-feature set and throws to fail the boot; we wrap the
7
+ // message with the owning feature's name so the error text points back at
8
+ // the feature that declared the check.
9
+ export function validateFeatureBootChecks(features: readonly FeatureDefinition[]): void {
10
+ const ctx: BootCheckContext = { features };
11
+ for (const feature of features) {
12
+ for (const check of feature.bootChecks) {
13
+ try {
14
+ check(ctx);
15
+ } catch (err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
18
+ }
19
+ }
20
+ }
21
+ }
@@ -119,11 +119,20 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
119
119
  }
120
120
  validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
121
121
  }
122
+ for (const [name, handler] of Object.entries(feature.streamHandlers)) {
123
+ if (!handler.access) {
124
+ throw new Error(
125
+ `Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
126
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
127
+ );
128
+ }
129
+ validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
130
+ }
122
131
  }
123
132
 
124
133
  export function validateAnonymousRateLimit(
125
134
  featureName: string,
126
- kind: "write" | "query",
135
+ kind: "write" | "query" | "stream",
127
136
  handlerName: string,
128
137
  access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
129
138
  rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
@@ -1,5 +1,5 @@
1
+ import { normalizeListColumn } from "../screen-helpers";
1
2
  import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
2
- import { normalizeListColumn } from "../types/screen";
3
3
 
4
4
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
5
5
  export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
@@ -1,22 +1,4 @@
1
- import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
2
1
  import type { FeatureDefinition } from "../types";
3
- import type { EntityDefinition, PiiAnnotations } from "../types/fields";
4
-
5
- // r.entity(...) is not the only way a feature exposes an entity shape:
6
- // r.projection(...) can carry an optional `entity` too (raw read-models with
7
- // no executor, e.g. billing-foundation's subscription table). Both V3 and V4
8
- // below need every entity a feature declares, not just the r.entity ones, or
9
- // a tenantOwned/pii field on a projection-only entity is invisible to the
10
- // guard it was annotated for.
11
- function entitiesOf(
12
- feature: FeatureDefinition,
13
- ): ReadonlyArray<readonly [string, EntityDefinition]> {
14
- const fromEntities = Object.entries(feature.entities ?? {});
15
- const fromProjections = Object.values(feature.projections ?? {})
16
- .filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
17
- .map((p) => [p.name, p.entity] as const);
18
- return [...fromEntities, ...fromProjections];
19
- }
20
2
 
21
3
  // Providers whose bytes do not survive a process restart. Only "inmemory"
22
4
  // today; extend if another ephemeral bundled provider lands.
@@ -75,97 +57,3 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
75
57
  }
76
58
  }
77
59
  }
78
-
79
- // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
80
- // export hook without a matching delete hook exports data under Art.20 but
81
- // never erases it on forget — an Art.17 violation. Hard boot failure: no app
82
- // should ship a GDPR export path with no erase path. Registry-level signal
83
- // only; runtime no-ops (a delete hook that silently skips) are not detectable
84
- // here — those are covered by the export/forget integration tests.
85
- export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
86
- for (const feature of features) {
87
- for (const usage of feature.extensionUsages) {
88
- if (usage.extensionName !== EXT_USER_DATA) continue;
89
- const hasExport = typeof usage.options?.["export"] === "function";
90
- const hasDelete = typeof usage.options?.["delete"] === "function";
91
- if (hasExport && !hasDelete) {
92
- throw new Error(
93
- `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
94
- );
95
- }
96
- }
97
- }
98
- }
99
-
100
- // V3: PII-entity-without-hook gate. V2 checks registered hooks for
101
- // completeness; V3 catches the entity nobody registered at all — fields
102
- // annotated as user-subject data (pii / userOwned) yet invisible to the
103
- // Art.15/20 export and Art.17 forget pipeline. Hard boot failure once
104
- // user-data-rights is mounted: a subject-data entity that skips the pipeline
105
- // is exactly the "feature built past GDPR" leak this gate exists to stop.
106
- // Matching is by entity name across all features (usage.entityName is
107
- // unqualified); a same-named entity in another feature can mask a gap —
108
- // accepted, as the common case is a distinct entity name.
109
- export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
110
- const featureNames = new Set(features.map((f) => f.name));
111
- if (!featureNames.has("user-data-rights")) {
112
- // skip: this guard only applies to apps that mount user-data-rights
113
- return;
114
- }
115
-
116
- const hookedEntities = new Set<string>();
117
- for (const f of features) {
118
- for (const usage of f.extensionUsages) {
119
- if (usage.extensionName === EXT_USER_DATA) hookedEntities.add(usage.entityName);
120
- }
121
- }
122
-
123
- for (const feature of features) {
124
- for (const [entityName, entity] of entitiesOf(feature)) {
125
- if (hookedEntities.has(entityName)) continue;
126
- const subjectFields = Object.entries(entity.fields)
127
- .filter(([, field]) => {
128
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
129
- return Boolean(annot.pii) || Boolean(annot.userOwned);
130
- })
131
- .map(([name]) => name);
132
- if (subjectFields.length === 0) continue;
133
- throw new Error(
134
- `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
135
- );
136
- }
137
- }
138
- }
139
-
140
- // V4: tenantOwned-entity-without-hook gate. Mirrors validateGdprPiiHookCoverage
141
- // but for EXT_TENANT_DATA when tenant-lifecycle is mounted.
142
- export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
143
- const featureNames = new Set(features.map((f) => f.name));
144
- if (!featureNames.has("tenant-lifecycle")) {
145
- // skip: this guard only applies to apps that mount tenant-lifecycle
146
- return;
147
- }
148
-
149
- const hookedEntities = new Set<string>();
150
- for (const f of features) {
151
- for (const usage of f.extensionUsages) {
152
- if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
153
- }
154
- }
155
-
156
- for (const feature of features) {
157
- for (const [entityName, entity] of entitiesOf(feature)) {
158
- if (hookedEntities.has(entityName)) continue;
159
- const tenantSubjectFields = Object.entries(entity.fields)
160
- .filter(([, field]) => {
161
- const annot = field as PiiAnnotations;
162
- return Boolean(annot.tenantOwned);
163
- })
164
- .map(([name]) => name);
165
- if (tenantSubjectFields.length === 0) continue;
166
- throw new Error(
167
- `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
168
- );
169
- }
170
- }
171
- }
@@ -3,6 +3,7 @@ import { QnTypes, qualifyEntityName } from "../qualified-name";
3
3
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
4
4
  import { validateActionWiring, validateFieldWiring } from "./action-wiring";
5
5
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
6
+ import { validateFeatureBootChecks } from "./boot-check";
6
7
  import {
7
8
  validateCircularDeps,
8
9
  validateConfigKeyAllowPerRequest,
@@ -30,12 +31,7 @@ import {
30
31
  validateTransitions,
31
32
  } from "./entity-handler";
32
33
  import { validateEntityListScreens } from "./entity-list-screens";
33
- import {
34
- validateGdprHookCompleteness,
35
- validateGdprPiiHookCoverage,
36
- validateGdprStoragePersistence,
37
- validateTenantDataHookCoverage,
38
- } from "./gdpr-storage";
34
+ import { validateGdprStoragePersistence } from "./gdpr-storage";
39
35
  import { validateI18nSurfaceKeys } from "./i18n-keys";
40
36
  import {
41
37
  collectKnownRoles,
@@ -198,9 +194,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
198
194
  validateEntityListScreens(features);
199
195
  validateExtensionPreSaveWiring(features);
200
196
  validateGdprStoragePersistence(features);
201
- validateGdprHookCompleteness(features);
202
- validateGdprPiiHookCoverage(features);
203
- validateTenantDataHookCoverage(features);
197
+ validateFeatureBootChecks(features);
204
198
 
205
199
  if (hasEncryptedFields) {
206
200
  // Availability check, not env-presence: eagerly building the keyring
@@ -7,6 +7,7 @@
7
7
  import { rowMetaFieldNames } from "../../db/table-builder";
8
8
  import { qualifyEntityName } from "../qualified-name";
9
9
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
10
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
10
11
  import type { FeatureDefinition } from "../types";
11
12
  import type {
12
13
  DashboardCustomPanel,
@@ -20,7 +21,6 @@ import type {
20
21
  ScreenDefinition,
21
22
  ToolbarAction,
22
23
  } from "../types/screen";
23
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
24
24
 
25
25
  // --- Screen validation ---
26
26
  //
@@ -106,6 +106,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
106
106
  relations: state.relations,
107
107
  writeHandlers: state.writeHandlers,
108
108
  queryHandlers: state.queryHandlers,
109
+ streamHandlers: state.streamHandlers,
109
110
  translations: state.translations,
110
111
  hooks: {
111
112
  validation: state.validationHooks,
@@ -136,6 +137,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
136
137
  extensionSelectors: state.extensionSelectors,
137
138
  exposedApis: state.exposedApis,
138
139
  usedApis: state.usedApis,
140
+ bootChecks: state.bootChecks,
139
141
  referenceData: state.referenceData,
140
142
  events: state.events,
141
143
  eventMigrations: state.eventMigrations,
@@ -1,81 +1,19 @@
1
1
  import type { ZodType, z } from "zod";
2
2
  import type { ContainsSecret } from "../secrets/types";
3
3
  import { runPipeline } from "./run-pipeline";
4
+ import type { HandlerContext, KumikoEventTypeMap, WriteEvent, WriteResult } from "./types";
4
5
  import type {
5
- AccessRule,
6
- HandlerContext,
7
- KumikoEventTypeMap,
8
- QueryEvent,
9
- RateLimitOption,
10
- WriteEvent,
11
- WriteResult,
12
- } from "./types";
13
- import type { PipelineDef } from "./types/step";
6
+ QueryHandlerDefinition,
7
+ WriteHandlerDefinition,
8
+ WriteHandlerInput,
9
+ } from "./types/define-handler";
14
10
 
15
- // --- Write Handler Definition ---
16
- //
17
- // TMap propagates the strict event-type-map through the handler's
18
- // HandlerContext. CRITICAL: TMap is declared as a generic parameter on the
19
- // FUNCTION (defineWriteHandler), not just on the type. Generic-functions
20
- // substitute TMap at the USE-site (the caller's compile context, where
21
- // the augmentation is visible); generic-type-aliases substitute at the
22
- // definition-site (framework's compile, where the augmentation isn't
23
- // visible) and collapse `keyof TMap` to `never`. See the spike-findings
24
- // memory for the empirical proof.
25
- //
26
- // Two authoring forms — `handler` (free-form) or `perform: stepsPipeline(...)`
27
- // (step-pipeline). A `perform` is compiled to a handler-function at
28
- // definition time; the dispatcher only ever sees `handler`.
29
-
30
- export type WriteHandlerDefinition<
31
- TName extends string = string,
32
- TSchema extends ZodType = ZodType,
33
- TData = unknown,
34
- TMap extends object = KumikoEventTypeMap,
35
- > = {
36
- readonly name: TName;
37
- readonly schema: TSchema;
38
- readonly access?: AccessRule;
39
- readonly unsafeSkipTransitionGuard?: boolean;
40
- readonly rateLimit?: RateLimitOption;
41
- readonly handler: (
42
- event: WriteEvent<z.infer<TSchema>>,
43
- context: HandlerContext<TMap>,
44
- ) => Promise<WriteResult<TData>>;
45
- // Preserved when the author wrote a `perform` block — the original
46
- // PipelineDef. Designer/AI/AST tools read this when present; the
47
- // dispatcher ignores it and just calls `handler`. Absent on free-form
48
- // handlers.
49
- readonly perform?: PipelineDef<z.infer<TSchema>, TData>;
50
- };
51
-
52
- // Author-facing input — accepts either the free-form `handler` or the
53
- // pipeline-form `perform`. defineWriteHandler narrows them to the
54
- // canonical WriteHandlerDefinition shape.
55
- export type WriteHandlerInput<
56
- TName extends string = string,
57
- TSchema extends ZodType = ZodType,
58
- TData = unknown,
59
- TMap extends object = KumikoEventTypeMap,
60
- > = {
61
- readonly name: TName;
62
- readonly schema: TSchema;
63
- readonly access?: AccessRule;
64
- readonly unsafeSkipTransitionGuard?: boolean;
65
- readonly rateLimit?: RateLimitOption;
66
- } & (
67
- | {
68
- readonly handler: (
69
- event: WriteEvent<z.infer<TSchema>>,
70
- context: HandlerContext<TMap>,
71
- ) => Promise<WriteResult<TData>>;
72
- readonly perform?: never;
73
- }
74
- | {
75
- readonly perform: PipelineDef<z.infer<TSchema>, TData>;
76
- readonly handler?: never;
77
- }
78
- );
11
+ export type {
12
+ QueryHandlerDefinition,
13
+ StreamHandlerDefinition,
14
+ WriteHandlerDefinition,
15
+ WriteHandlerInput,
16
+ } from "./types/define-handler";
79
17
 
80
18
  export function defineWriteHandler<
81
19
  const TName extends string,
@@ -160,24 +98,6 @@ export function defineWriteHandler<
160
98
  return { ...base, handler: def.handler };
161
99
  }
162
100
 
163
- // --- Query Handler Definition ---
164
-
165
- export type QueryHandlerDefinition<
166
- TName extends string = string,
167
- TSchema extends ZodType = ZodType,
168
- TResult = unknown,
169
- TMap extends object = KumikoEventTypeMap,
170
- > = {
171
- readonly name: TName;
172
- readonly schema: TSchema;
173
- readonly access?: AccessRule;
174
- readonly rateLimit?: RateLimitOption;
175
- readonly handler: (
176
- query: QueryEvent<z.infer<TSchema>>,
177
- context: HandlerContext<TMap>,
178
- ) => Promise<TResult>;
179
- };
180
-
181
101
  export function defineQueryHandler<
182
102
  const TName extends string,
183
103
  TSchema extends ZodType,
@@ -18,6 +18,21 @@ import type {
18
18
  QueryHandlerDef,
19
19
  WriteHandlerDef,
20
20
  } from "./types";
21
+ import type {
22
+ EntityCrudRegistrar,
23
+ EntityCrudVerb,
24
+ EntityHandlerOptions,
25
+ EntityQueryHandlerOptions,
26
+ RegisterEntityCrudOptions,
27
+ } from "./types/entity-handlers";
28
+
29
+ export type {
30
+ EntityCrudRegistrar,
31
+ EntityCrudVerb,
32
+ EntityHandlerOptions,
33
+ EntityQueryHandlerOptions,
34
+ RegisterEntityCrudOptions,
35
+ } from "./types/entity-handlers";
21
36
 
22
37
  // Convention-based handler factories for event-sourced aggregates.
23
38
  //
@@ -330,16 +345,6 @@ export function defineEntityQueryHandler(
330
345
  // would need a Branded-EntityDefinition with `softDelete: true` literal
331
346
  // — feasible but not yet wired; the runtime guard catches misuse.
332
347
 
333
- type EntityHandlerOptions = { readonly access?: AccessRule };
334
- type EntityQueryHandlerOptions = EntityHandlerOptions & {
335
- /** Reads across every tenant instead of the caller's own — for a
336
- * SystemAdmin-only operator inspector over an otherwise tenant-scoped
337
- * entity. Scope this to the ONE handler that needs it rather than making
338
- * the whole feature r.systemScope(), which would drop tenant isolation
339
- * from every other handler the feature registers too. */
340
- readonly crossTenant?: boolean;
341
- };
342
-
343
348
  // @wrapper-known semantic-alias
344
349
  export function defineEntityCreateHandler(
345
350
  entityName: string,
@@ -446,23 +451,6 @@ export function defineProjectionQueryHandler(
446
451
  };
447
452
  }
448
453
 
449
- type EntityCrudVerb = "create" | "update" | "delete" | "restore" | "list" | "detail";
450
-
451
- export type RegisterEntityCrudOptions = {
452
- readonly write?: EntityHandlerOptions;
453
- readonly read?: EntityQueryHandlerOptions;
454
- readonly verbs?: Partial<Record<EntityCrudVerb, boolean>>;
455
- /** Default true. Set false when the entity was already registered (e.g. before r.relation). */
456
- readonly registerEntity?: boolean;
457
- };
458
-
459
- /** Minimal registrar surface — keeps entity-handlers free of define-feature imports. */
460
- export type EntityCrudRegistrar = {
461
- entity(name: string, definition: EntityDefinition): unknown;
462
- writeHandler(def: WriteHandlerDef): unknown;
463
- queryHandler(def: QueryHandlerDef): unknown;
464
- };
465
-
466
454
  function defaultCrudVerbs(entity: EntityDefinition): Record<EntityCrudVerb, boolean> {
467
455
  return {
468
456
  create: true,
@@ -78,6 +78,15 @@ defineFeature("todoList", (r) => {
78
78
  access: { openToAll: true },
79
79
  });
80
80
 
81
+ r.streamHandler({
82
+ name: "task:stream",
83
+ schema: z.object({ prompt: z.string() }),
84
+ handler: async function* (input, ctx) {
85
+ yield "token";
86
+ },
87
+ access: { roles: ["user"] },
88
+ });
89
+
81
90
  r.hook({
82
91
  type: "postSave",
83
92
  target: "task",
@@ -195,7 +204,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
195
204
  expect(unknowns).toEqual([]);
196
205
  });
197
206
 
198
- test("alle 28 Pattern-Kinds sind erfasst", () => {
207
+ test("alle Pattern-Kinds sind erfasst", () => {
199
208
  const kinds: ReadonlySet<string> = new Set(result.patterns.map((p) => p.kind));
200
209
  const expected = [
201
210
  // Static
@@ -219,6 +228,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
219
228
  "screen",
220
229
  "writeHandler",
221
230
  "queryHandler",
231
+ "streamHandler",
222
232
  "hook",
223
233
  "job",
224
234
  "notification",