@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
@@ -1,23 +1,16 @@
1
1
  // V1 — GDPR storage-persistence boot guard. Catches the prod failure class:
2
2
  // user-data-rights mounted but exports land in an ephemeral / missing store,
3
3
  // and s3-env selected as the GDPR store without its env vars set.
4
- // V2 — export-without-erase guard. Catches features that register an export
5
- // hook but no delete hook (Art.17 violation).
6
- // V3 — PII-entity-without-hook guard. Catches entities with pii/userOwned
7
- // fields that no feature registers an EXT_USER_DATA hook for.
8
- // V4tenantOwned-entity-without-hook guard. Mirrors V3 for EXT_TENANT_DATA.
4
+ //
5
+ // V2-V4 (export-without-erase / PII-entity-without-hook / tenantOwned-entity-
6
+ // without-hook) moved off this framework-internal validator onto
7
+ // `r.bootCheck()` calls declared by user-data-rights / user-data-rights-
8
+ // defaults (#1314) see boot-checks.test.ts in the bundled-features package
9
+ // for their coverage.
9
10
 
10
11
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
11
- import { integer, type SchemaTable, table, uuid } from "../../db/dialect";
12
- import {
13
- validateGdprHookCompleteness,
14
- validateGdprPiiHookCoverage,
15
- validateGdprStoragePersistence,
16
- validateTenantDataHookCoverage,
17
- } from "../boot-validator/gdpr-storage";
12
+ import { validateGdprStoragePersistence } from "../boot-validator/gdpr-storage";
18
13
  import { defineFeature } from "../define-feature";
19
- import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
20
- import { createEntity, createLongTextField, createTextField } from "../factories";
21
14
 
22
15
  const udr = () => defineFeature("user-data-rights", () => {});
23
16
  const fileProvider = (name: string) =>
@@ -25,26 +18,8 @@ const fileProvider = (name: string) =>
25
18
  r.useExtension("fileProvider", name);
26
19
  });
27
20
 
28
- // Throwaway Drizzle table for r.projection() registrations — the projection
29
- // runtime behaviour isn't under test here, only the guard's field scan.
30
- const testProjectionTable = table("test_projection", {
31
- id: uuid("id").primaryKey(),
32
- count: integer("count").notNull().default(0),
33
- }) as unknown as SchemaTable;
34
-
35
21
  const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
36
22
 
37
- // V2/V3 are hard boot gates now — capture the thrown message so a single case
38
- // can assert several substrings (feature, entity, article).
39
- function catchMessage(fn: () => void): string {
40
- try {
41
- fn();
42
- } catch (e) {
43
- return e instanceof Error ? e.message : String(e);
44
- }
45
- throw new Error("expected function to throw, but it did not");
46
- }
47
-
48
23
  describe("validateGdprStoragePersistence (V1)", () => {
49
24
  let warnSpy: ReturnType<typeof spyOn>;
50
25
  let savedEnv: Array<readonly [string, string | undefined]>;
@@ -103,204 +78,3 @@ describe("validateGdprStoragePersistence (V1)", () => {
103
78
  expect(warnSpy).not.toHaveBeenCalled();
104
79
  });
105
80
  });
106
-
107
- describe("validateGdprHookCompleteness (V2)", () => {
108
- const exportFn = async () => null;
109
- const deleteFn = async () => {};
110
-
111
- test("export + delete hooks → no throw", () => {
112
- const f = defineFeature("my-feature", (r) => {
113
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
114
- });
115
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
116
- });
117
-
118
- test("export hook without delete hook → Art.17 throw", () => {
119
- const f = defineFeature("my-feature", (r) => {
120
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
121
- });
122
- const msg = catchMessage(() => validateGdprHookCompleteness([f]));
123
- expect(msg).toContain("my-feature");
124
- expect(msg).toContain("myEntity");
125
- expect(msg).toContain("Art.17");
126
- });
127
-
128
- test("delete hook only (no export) → no throw", () => {
129
- const f = defineFeature("my-feature", (r) => {
130
- r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
131
- });
132
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
133
- });
134
-
135
- test("no EXT_USER_DATA hooks at all → no throw", () => {
136
- const f = defineFeature("my-feature", (r) => {
137
- r.useExtension("fileProvider", "s3");
138
- });
139
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
140
- });
141
-
142
- test("multiple features, one missing delete → throws naming the offender", () => {
143
- const good = defineFeature("good", (r) => {
144
- r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
145
- });
146
- const bad = defineFeature("bad", (r) => {
147
- r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
148
- });
149
- const msg = catchMessage(() => validateGdprHookCompleteness([good, bad]));
150
- expect(msg).toContain("entityB");
151
- });
152
- });
153
-
154
- describe("validateGdprPiiHookCoverage (V3)", () => {
155
- const exportFn = async () => null;
156
- const deleteFn = async () => {};
157
-
158
- const piiFeature = () =>
159
- defineFeature("crm", (r) => {
160
- r.entity(
161
- "contact",
162
- createEntity({
163
- fields: {
164
- email: createTextField({ pii: true }),
165
- note: createLongTextField({ userOwned: { ownerField: "authorId" } }),
166
- authorId: { type: "reference", entity: "user" },
167
- },
168
- }),
169
- );
170
- });
171
-
172
- test("user-data-rights not mounted → no throw", () => {
173
- expect(() => validateGdprPiiHookCoverage([piiFeature()])).not.toThrow();
174
- });
175
-
176
- test("pii entity without any EXT_USER_DATA hook → throws naming entity and fields", () => {
177
- const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), piiFeature()]));
178
- expect(msg).toContain('"contact"');
179
- expect(msg).toContain("email");
180
- expect(msg).toContain("note");
181
- expect(msg).toContain("Art.17");
182
- });
183
-
184
- test("pii entity with hook registered by another feature → no throw", () => {
185
- const hooks = defineFeature("crm-user-data", (r) => {
186
- r.useExtension(EXT_USER_DATA, "contact", { export: exportFn, delete: deleteFn });
187
- });
188
- expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
189
- });
190
-
191
- test("no-op hook is the intentional escape hatch → no throw", () => {
192
- const hooks = defineFeature("crm-user-data", (r) => {
193
- // Escape hatch: erasure handled elsewhere (crypto-shredding key-erase),
194
- // so the pipeline hook is a deliberate no-op.
195
- r.useExtension(EXT_USER_DATA, "contact", {
196
- export: async () => null,
197
- delete: async () => {},
198
- });
199
- });
200
- expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
201
- });
202
-
203
- test("entity without subject annotations → no throw", () => {
204
- const plain = defineFeature("catalog", (r) => {
205
- r.entity(
206
- "product",
207
- createEntity({
208
- fields: { sku: createTextField({ allowPlaintext: "is-business-data" }) },
209
- }),
210
- );
211
- });
212
- expect(() => validateGdprPiiHookCoverage([udr(), plain])).not.toThrow();
213
- });
214
-
215
- test("userOwned field alone counts as user-subject data → throws", () => {
216
- const f = defineFeature("notes", (r) => {
217
- r.entity(
218
- "note",
219
- createEntity({
220
- fields: {
221
- body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
222
- authorId: { type: "reference", entity: "user" },
223
- },
224
- }),
225
- );
226
- });
227
- const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), f]));
228
- expect(msg).toContain('"note"');
229
- });
230
- });
231
-
232
- describe("validateTenantDataHookCoverage (V4)", () => {
233
- const destroyFn = async () => {};
234
- const tenantLifecycle = () => defineFeature("tenant-lifecycle", () => {});
235
-
236
- const tenantEntityFeature = () =>
237
- defineFeature("billing", (r) => {
238
- r.entity(
239
- "subscription",
240
- createEntity({
241
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
242
- }),
243
- );
244
- });
245
-
246
- test("tenant-lifecycle not mounted → no throw", () => {
247
- expect(() => validateTenantDataHookCoverage([tenantEntityFeature()])).not.toThrow();
248
- });
249
-
250
- test("tenantOwned entity without any EXT_TENANT_DATA hook → throws naming entity and field", () => {
251
- const msg = catchMessage(() =>
252
- validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature()]),
253
- );
254
- expect(msg).toContain('"subscription"');
255
- expect(msg).toContain("providerCustomerId");
256
- });
257
-
258
- test("tenantOwned entity with hook registered → no throw", () => {
259
- const hooks = defineFeature("billing-hooks", (r) => {
260
- r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
261
- });
262
- expect(() =>
263
- validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature(), hooks]),
264
- ).not.toThrow();
265
- });
266
-
267
- // Regression: billing-foundation's real `subscription` shape — r.projection()
268
- // (no executor, no r.entity) carrying an `entity` reference for its
269
- // tenantOwned fields. Before this fix, feature.entities-only scanning made
270
- // this shape invisible to the guard.
271
- test("tenantOwned field on a projection-only entity (no r.entity) is still caught", () => {
272
- const projectionFeature = defineFeature("billing", (r) => {
273
- r.projection({
274
- name: "subscription",
275
- source: "subscription",
276
- table: testProjectionTable,
277
- entity: createEntity({
278
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
279
- }),
280
- apply: {},
281
- });
282
- });
283
- const msg = catchMessage(() =>
284
- validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
285
- );
286
- expect(msg).toContain("providerCustomerId");
287
- });
288
-
289
- test("tenantOwned field on a projection-only entity WITH a hook → no throw", () => {
290
- const projectionFeature = defineFeature("billing", (r) => {
291
- r.projection({
292
- name: "subscription",
293
- source: "subscription",
294
- table: testProjectionTable,
295
- entity: createEntity({
296
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
297
- }),
298
- apply: {},
299
- });
300
- r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
301
- });
302
- expect(() =>
303
- validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
304
- ).not.toThrow();
305
- });
306
- });
@@ -590,6 +590,52 @@ describe("boot-validator", () => {
590
590
  expect(() => validateBoot(features)).not.toThrow();
591
591
  });
592
592
 
593
+ test("throws when a stream handler has no access rule", () => {
594
+ const features = [
595
+ defineFeature("a", (r) => {
596
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
597
+ }),
598
+ ];
599
+ expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
600
+ });
601
+
602
+ test("accepts openToAll access rule on a stream handler", () => {
603
+ const features = [
604
+ defineFeature("a", (r) => {
605
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
606
+ access: { openToAll: true },
607
+ });
608
+ }),
609
+ ];
610
+ expect(() => validateBoot(features)).not.toThrow();
611
+ });
612
+
613
+ test("throws when an anonymous stream handler uses a user-bucketed rate limit", () => {
614
+ const features = [
615
+ defineFeature("a", (r) => {
616
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
617
+ access: { roles: ["anonymous"] },
618
+ rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
619
+ });
620
+ }),
621
+ ];
622
+ expect(() => validateBoot(features)).toThrow(
623
+ /stream handler "a:stream:chat:complete" allows anonymous callers/i,
624
+ );
625
+ });
626
+
627
+ test("accepts an anonymous stream handler with an ip-bucketed rate limit", () => {
628
+ const features = [
629
+ defineFeature("a", (r) => {
630
+ r.streamHandler("chat:complete", z.object({}), async function* () {}, {
631
+ access: { roles: ["anonymous"] },
632
+ rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
633
+ });
634
+ }),
635
+ ];
636
+ expect(() => validateBoot(features)).not.toThrow();
637
+ });
638
+
593
639
  describe("config key bounds consistency", () => {
594
640
  test("accepts number key with consistent bounds + default", () => {
595
641
  const features = [
@@ -1,19 +1,32 @@
1
1
  import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- // Bun.Glob is only used by scanForCandidates / runCodemod, not by
6
- // analyzeFile or convertFile. Those higher-level functions are tested
7
- // in the integration suite; unit tests here focus on file-level
8
- // operations which don't need Glob. A stub class prevents import
9
- // errors in bun (which runs on Node, not Bun).
5
+ // Bun.Glob is only used by scanForCandidates / runCodemod. Emulate
6
+ // **/*.write.ts via node:fs so scan/convert paths stay testable.
7
+ function collectWriteTsRelPaths(rootDir: string, dir = rootDir, prefix = ""): string[] {
8
+ const out: string[] = [];
9
+ for (const name of readdirSync(dir)) {
10
+ const rel = prefix ? `${prefix}/${name}` : name;
11
+ const full = join(dir, name);
12
+ if (statSync(full).isDirectory()) {
13
+ out.push(...collectWriteTsRelPaths(rootDir, full, rel));
14
+ } else if (name.endsWith(".write.ts")) {
15
+ out.push(rel);
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
10
21
  mock.module("bun", () => {
11
- class StubGlob {
12
- scanSync(): never {
13
- throw new Error("Bun.Glob is not available in unit-test mode");
22
+ class FsGlob {
23
+ constructor(private readonly pattern: string) {}
24
+ scanSync(rootDir: string): string[] {
25
+ if (this.pattern !== "**/*.write.ts") return [];
26
+ return collectWriteTsRelPaths(rootDir).sort();
14
27
  }
15
28
  }
16
- return { Glob: StubGlob };
29
+ return { Glob: FsGlob };
17
30
  });
18
31
 
19
32
  import {
@@ -21,6 +34,8 @@ import {
21
34
  analyzeHandlerArrow,
22
35
  convertFile,
23
36
  generatePerformBlock,
37
+ runCodemod,
38
+ scanForCandidates,
24
39
  } from "../codemod/index";
25
40
 
26
41
  const tmpDir = join(__dirname, "__codemod_fixtures__");
@@ -31,6 +46,20 @@ function writeFixture(name: string, content: string): string {
31
46
  return p;
32
47
  }
33
48
 
49
+ function writeFixtureIn(dir: string, name: string, content: string): string {
50
+ const p = join(dir, name);
51
+ mkdirSync(join(p, ".."), { recursive: true });
52
+ writeFileSync(p, content, "utf8");
53
+ return p;
54
+ }
55
+
56
+ function freshScanRoot(name: string): string {
57
+ const dir = join(tmpDir, name);
58
+ rmSync(dir, { recursive: true, force: true });
59
+ mkdirSync(dir, { recursive: true });
60
+ return dir;
61
+ }
62
+
34
63
  beforeAll(() => {
35
64
  rmSync(tmpDir, { recursive: true, force: true });
36
65
  mkdirSync(tmpDir, { recursive: true });
@@ -399,6 +428,31 @@ export const h = defineWriteHandler({
399
428
  // ts-morph should handle this gracefully (parse error → no call found)
400
429
  expect(result.status).toBe("skipped");
401
430
  });
431
+
432
+ it("returns error when the path is not a readable file", async () => {
433
+ const dirPath = join(tmpDir, "dir-not-file.write.ts");
434
+ rmSync(dirPath, { recursive: true, force: true });
435
+ mkdirSync(dirPath, { recursive: true });
436
+ const result = await convertFile(dirPath);
437
+ expect(result.status).toBe("error");
438
+ expect(result.reason).toBeDefined();
439
+ });
440
+
441
+ it("verbose logs when skipping a non-trivial handler", async () => {
442
+ const p = writeFixture("verbose-skip-file.write.ts", complexContent);
443
+ const logs: string[] = [];
444
+ const origLog = console.log;
445
+ console.log = (...args: unknown[]) => {
446
+ logs.push(args.map(String).join(" "));
447
+ };
448
+ try {
449
+ const result = await convertFile(p, undefined, { verbose: true });
450
+ expect(result.status).toBe("skipped");
451
+ expect(logs.some((l) => l.includes("non-trivial handler"))).toBe(true);
452
+ } finally {
453
+ console.log = origLog;
454
+ }
455
+ });
402
456
  });
403
457
  });
404
458
 
@@ -509,6 +563,81 @@ describe("analyzeHandlerArrow", () => {
509
563
  expect(result.isGuardedCreate).toBe(true);
510
564
  expect(result.isSimpleExecutorCreate).toBe(false);
511
565
  });
566
+
567
+ it("returns non-convertible defaults for async ( without =>", () => {
568
+ const result = analyzeHandlerArrow("async (event, ctx) { return { isSuccess: true } }");
569
+ expect(result.isStaticReturn).toBe(false);
570
+ expect(result.isSimpleExecutorCreate).toBe(false);
571
+ expect(result.isSimpleExecutorUpdate).toBe(false);
572
+ expect(result.hasConditionalLogic).toBe(false);
573
+ expect(result.isExpressionBodyCreate).toBe(false);
574
+ expect(result.isGuardedCreate).toBe(false);
575
+ });
576
+ });
577
+
578
+ describe("scanForCandidates", () => {
579
+ it("returns empty list when no *.write.ts files exist", () => {
580
+ const root = freshScanRoot("scan-empty");
581
+ expect(scanForCandidates(root)).toEqual([]);
582
+ });
583
+
584
+ it("finds and classifies *.write.ts fixtures under rootDir", () => {
585
+ const root = freshScanRoot("scan-mixed");
586
+ writeFixtureIn(root, "freeform.write.ts", staticReturnContent);
587
+ writeFixtureIn(root, "already-pipeline.write.ts", alreadyPipelineContent);
588
+ writeFixtureIn(root, "nested/deep.write.ts", queryHandlerContent);
589
+
590
+ const results = scanForCandidates(root);
591
+ expect(results.map((r) => r.filePath).sort()).toEqual(
592
+ [
593
+ join(root, "already-pipeline.write.ts"),
594
+ join(root, "freeform.write.ts"),
595
+ join(root, "nested/deep.write.ts"),
596
+ ].sort(),
597
+ );
598
+ expect(results.find((r) => r.pattern === "free-form-write")?.convertible).toBe(true);
599
+ expect(results.find((r) => r.pattern === "pipeline-write")?.convertible).toBe(false);
600
+ expect(results.find((r) => r.pattern === "query-handler")?.convertible).toBe(false);
601
+ });
602
+ });
603
+
604
+ describe("runCodemod", () => {
605
+ it("returns empty report when scan finds no convertible handlers", async () => {
606
+ const root = freshScanRoot("run-empty");
607
+ const report = await runCodemod(root);
608
+ expect(report).toEqual({ results: [], converted: 0, skipped: 0, errors: 0 });
609
+ });
610
+
611
+ it("converts free-form write handlers discovered by scan", async () => {
612
+ const root = freshScanRoot("run-convert");
613
+ const p = writeFixtureIn(root, "to-convert.write.ts", staticReturnContent);
614
+ const report = await runCodemod(root);
615
+ expect(report.converted).toBe(1);
616
+ expect(report.skipped).toBe(0);
617
+ expect(report.errors).toBe(0);
618
+ expect(readFileSync(p, "utf8")).toContain("perform: stepsPipeline");
619
+ });
620
+
621
+ it("verbose logs per-file outcomes", async () => {
622
+ const root = freshScanRoot("run-verbose");
623
+ writeFixtureIn(root, "verbose-convert.write.ts", staticReturnContent);
624
+ writeFixtureIn(root, "verbose-skip.write.ts", complexContent);
625
+
626
+ const logs: string[] = [];
627
+ const origLog = console.log;
628
+ console.log = (...args: unknown[]) => {
629
+ logs.push(args.map(String).join(" "));
630
+ };
631
+ try {
632
+ const report = await runCodemod(root, { verbose: true });
633
+ expect(report.converted).toBe(1);
634
+ expect(report.skipped).toBe(1);
635
+ expect(logs.some((l) => l.includes("verbose-convert.write.ts"))).toBe(true);
636
+ expect(logs.some((l) => l.includes("verbose-skip.write.ts"))).toBe(true);
637
+ } finally {
638
+ console.log = origLog;
639
+ }
640
+ });
512
641
  });
513
642
 
514
643
  describe("generatePerformBlock", () => {
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineRoles } from "../define-roles";
3
+
4
+ describe("defineRoles", () => {
5
+ test("maps each role name to itself", () => {
6
+ const roles = defineRoles(["Admin", "SystemAdmin", "Driver"] as const);
7
+ expect(roles.Admin).toBe("Admin");
8
+ expect(roles.SystemAdmin).toBe("SystemAdmin");
9
+ expect(roles.Driver).toBe("Driver");
10
+ });
11
+
12
+ test("returns an object with exactly the given keys", () => {
13
+ const roles = defineRoles(["A", "B"] as const);
14
+ expect(Object.keys(roles).sort()).toEqual(["A", "B"]);
15
+ });
16
+
17
+ test("an empty role list returns an empty object", () => {
18
+ const roles = defineRoles([] as const);
19
+ expect(roles).toEqual({});
20
+ });
21
+ });
@@ -14,6 +14,7 @@ import {
14
14
  createTextField,
15
15
  } from "../factories";
16
16
  import { createApp, createRegistry, defineFeature } from "../index";
17
+ import { buildMinimalCtx } from "./_pipeline-test-utils";
17
18
 
18
19
  // --- defineFeature ---
19
20
 
@@ -118,6 +119,33 @@ describe("defineFeature", () => {
118
119
  expect(ref?.name).toBe("order:list");
119
120
  });
120
121
 
122
+ test("streamHandler registers on feature.streamHandlers with schema/access/handler intact", async () => {
123
+ let ref: { name: string } | undefined;
124
+ const feature = defineFeature("test", (r) => {
125
+ ref = r.streamHandler(
126
+ "chat:complete",
127
+ z.object({ prompt: z.string() }),
128
+ async function* (query) {
129
+ yield `echo:${query.payload.prompt}`;
130
+ },
131
+ { access: { openToAll: true } },
132
+ );
133
+ });
134
+ expect(ref?.name).toBe("chat:complete");
135
+
136
+ const stored = feature.streamHandlers["chat:complete"];
137
+ expect(stored?.access).toEqual({ openToAll: true });
138
+
139
+ const chunks: string[] = [];
140
+ for await (const chunk of stored!.handler(
141
+ { type: "chat:complete", payload: { prompt: "hi" }, user: createTestUser() },
142
+ buildMinimalCtx(),
143
+ )) {
144
+ chunks.push(chunk as string);
145
+ }
146
+ expect(chunks).toEqual(["echo:hi"]);
147
+ });
148
+
121
149
  test("r.defineEvent returns typed EventDef and registers on feature", () => {
122
150
  let eventRef: { name: string } | undefined;
123
151
  const feature = defineFeature("orders", (r) => {
@@ -0,0 +1,24 @@
1
+ // Type-only fixture for the KumikoEventTypeMap declaration-merging channel
2
+ // (#1394). event-type-map.ts moved the marker interfaces behind a re-export
3
+ // chain (kumiko-types -> engine/types/event-type-map.ts shim -> engine
4
+ // barrel), while the codegen augmentation channel
5
+ // (dev-server/src/codegen/render.ts) still targets
6
+ // `declare module "@cosmicdrift/kumiko-framework/engine"`. If a future
7
+ // change breaks that re-export chain, augmentation silently stops merging
8
+ // and every event payload falls back to `unknown` with no compile error —
9
+ // this test only catches it because tsc checks the `declare module` below
10
+ // against the SAME specifier the codegen emits.
11
+ import { expectTypeOf, test } from "bun:test";
12
+ import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-framework/engine";
13
+
14
+ declare module "@cosmicdrift/kumiko-framework/engine" {
15
+ interface KumikoEventTypeMap {
16
+ "event-type-map-fixture:probe.created": { readonly probe: true };
17
+ }
18
+ }
19
+
20
+ test("augmenting KumikoEventTypeMap via the engine specifier merges into the relocated interface", () => {
21
+ expectTypeOf<KumikoEventTypeMap["event-type-map-fixture:probe.created"]>().toEqualTypeOf<{
22
+ readonly probe: true;
23
+ }>();
24
+ });
@@ -0,0 +1,80 @@
1
+ // Dilution reclaim: integration lcov registers every facade getter as DA:N,0
2
+ // when the file loads, but unit only instruments getters that a test touches.
3
+ // Calling every getter once in unit raises LF+LH so the badge merge stops
4
+ // counting those one-liners as uncovered.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createRegistry } from "../registry";
8
+
9
+ describe("buildRegistryFacade — getter sweep", () => {
10
+ test("empty registry: every getter is callable without throw", () => {
11
+ const r = createRegistry([]);
12
+
13
+ expect(r.features.size).toBe(0);
14
+ expect(r.getFeature("x")).toBeUndefined();
15
+ expect(r.hasRateLimitedHandler()).toBe(false);
16
+ expect(r.getEntity("x")).toBeUndefined();
17
+ expect(r.getAllEntities().size).toBe(0);
18
+ expect(r.getWriteHandler("x")).toBeUndefined();
19
+ expect(r.getQueryHandler("x")).toBeUndefined();
20
+ expect(r.getAllQueryHandlers().size).toBe(0);
21
+ expect(r.getSearchableFields("x")).toEqual([]);
22
+ expect(r.getSortableFields("x")).toEqual([]);
23
+ expect(r.getRelations("x")).toEqual({});
24
+ expect(r.getSearchIncludes("x").size).toBe(0);
25
+ expect(r.getIncomingRelations("x")).toEqual([]);
26
+ expect(r.getPreSaveHooks("x")).toEqual([]);
27
+ expect(r.getPostSaveHooks("x")).toEqual([]);
28
+ expect(r.getPreDeleteHooks("x")).toEqual([]);
29
+ expect(r.getPostDeleteHooks("x")).toEqual([]);
30
+ expect(r.getPreQueryHooks("x")).toEqual([]);
31
+ expect(r.getPostQueryHooks("x")).toEqual([]);
32
+ expect(r.getEntityPostSaveHooks("x")).toEqual([]);
33
+ expect(r.getEntityPreDeleteHooks("x")).toEqual([]);
34
+ expect(r.getEntityPostDeleteHooks("x")).toEqual([]);
35
+ expect(r.getEntityPostQueryHooks("x")).toEqual([]);
36
+ expect(r.getSearchPayloadExtensions("x")).toEqual([]);
37
+ expect(r.getAllTranslations()).toBeDefined();
38
+ expect(r.getHandlerEntity("x")).toBeUndefined();
39
+ expect(r.isHandlerSystemScoped("x")).toBe(false);
40
+ expect(r.getHandlerFeature("x")).toBeUndefined();
41
+ expect(r.getAllMetrics().size).toBe(0);
42
+ expect(r.getAllSecretKeys().size).toBe(0);
43
+ expect(r.getSecretKey("x")).toBeUndefined();
44
+ expect(r.getConfigKey("x")).toBeUndefined();
45
+ expect(r.getAllConfigKeys().size).toBe(0);
46
+ expect(r.getJob("x")).toBeUndefined();
47
+ expect(r.getAllJobs().size).toBe(0);
48
+ expect(r.getEvent("x")).toBeUndefined();
49
+ expect(r.getEventUpcasters().size).toBe(0);
50
+ expect(r.getExtension("x")).toBeUndefined();
51
+ expect(r.getExtensionUsages("x")).toEqual([]);
52
+ expect(r.getAllExtensionSelectors().size).toBe(0);
53
+ expect(r.getAllNotifications().size).toBe(0);
54
+ expect(r.getAllReferenceData()).toEqual([]);
55
+ expect(r.getAllConfigSeeds()).toEqual([]);
56
+ expect(r.getProjectionsForSource("x")).toEqual([]);
57
+ expect(r.getAllProjections().size).toBe(0);
58
+ expect(r.getAllStoreTables().size).toBe(0);
59
+ expect(r.getAllMultiStreamProjections().size).toBe(0);
60
+ expect(r.getMultiStreamProjectionFeature("x")).toBeUndefined();
61
+ expect(r.getAuthClaimsHooks()).toEqual([]);
62
+ expect(r.getAllClaimKeys().size).toBe(0);
63
+ expect(r.getClaimKey("x")).toBeUndefined();
64
+ expect(r.getAllScreens().size).toBe(0);
65
+ expect(r.getScreen("x")).toBeUndefined();
66
+ expect(r.getScreenFeature("x")).toBeUndefined();
67
+ expect(r.getScreensByEntity("x")).toEqual([]);
68
+ expect(r.getAllNavs().size).toBe(0);
69
+ expect(r.getNav("x")).toBeUndefined();
70
+ expect(r.getNavFeature("x")).toBeUndefined();
71
+ expect(r.getNavsByParent("x")).toEqual([]);
72
+ expect(r.getTopLevelNavs()).toEqual([]);
73
+ expect(r.getAllWorkspaces().size).toBe(0);
74
+ expect(r.getWorkspace("x")).toBeUndefined();
75
+ expect(r.getWorkspaceFeature("x")).toBeUndefined();
76
+ expect(r.getWorkspaceNavs("x")).toEqual([]);
77
+ expect(r.getDefaultWorkspace()).toBeUndefined();
78
+ expect(r.getTreeActions("x")).toBeUndefined();
79
+ });
80
+ });