@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -0,0 +1,819 @@
1
+ // Runtime-validator tests for parsePatternChanges (kumiko-framework#3137).
2
+ // The round-trip guard is the load-bearing test: every FeaturePattern the
3
+ // real-feature/recipe corpus produces must survive an add-change parse
4
+ // byte-for-byte, or the schema has drifted from patterns.ts.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { resolve } from "node:path";
8
+ import { Project, type SourceFile } from "ts-morph";
9
+ import type { z } from "zod";
10
+ import { parseFeatureFile, parseSourceFile } from "../parse";
11
+ import { applyChanges, type PatternId } from "../patch";
12
+ import {
13
+ type PATTERN_ID_SCHEMAS_BY_KIND,
14
+ type PATTERN_SCHEMAS_BY_KIND,
15
+ parsePatternChanges,
16
+ } from "../pattern-change-schema";
17
+ import type { FeaturePattern, FeaturePatternKind } from "../patterns";
18
+
19
+ // Compile-time guard (AC5): every pattern/patternId schema's z.output must
20
+ // have exactly the same keys as, and be assignable to, the domain type it
21
+ // stands for. `keyof` ignores readonly, so readonly fields on FeaturePattern
22
+ // are not an obstacle here; only tsc checks this (bun strips types at
23
+ // runtime). A mapped type computes its value per key independently; a plain
24
+ // value-level assignment to `Record<Kind, true>` then forces tsc to report
25
+ // the exact offending kind if any value there is `false` (wrapping this in a
26
+ // generic `Expect<T extends true>` check instead loses per-key narrowing and
27
+ // only ever reports "boolean is not assignable to true" with no key name).
28
+ type Equal<A, B> =
29
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
30
+
31
+ type PatternSchemaKeysMatch = {
32
+ [K in FeaturePatternKind]: Equal<
33
+ keyof z.output<(typeof PATTERN_SCHEMAS_BY_KIND)[K]>,
34
+ keyof Extract<FeaturePattern, { kind: K }>
35
+ >;
36
+ };
37
+ type PatternSchemaAssignable = {
38
+ [K in FeaturePatternKind]: z.output<(typeof PATTERN_SCHEMAS_BY_KIND)[K]> extends Extract<
39
+ FeaturePattern,
40
+ { kind: K }
41
+ >
42
+ ? true
43
+ : false;
44
+ };
45
+ export const _patternSchemaKeysMatch: Record<FeaturePatternKind, true> =
46
+ null as unknown as PatternSchemaKeysMatch;
47
+ export const _patternSchemaAssignable: Record<FeaturePatternKind, true> =
48
+ null as unknown as PatternSchemaAssignable;
49
+
50
+ type PatternIdSchemaKeysMatch = {
51
+ [K in PatternId["kind"]]: Equal<
52
+ keyof z.output<(typeof PATTERN_ID_SCHEMAS_BY_KIND)[K]>,
53
+ keyof Extract<PatternId, { kind: K }>
54
+ >;
55
+ };
56
+ type PatternIdSchemaAssignable = {
57
+ [K in PatternId["kind"]]: z.output<(typeof PATTERN_ID_SCHEMAS_BY_KIND)[K]> extends Extract<
58
+ PatternId,
59
+ { kind: K }
60
+ >
61
+ ? true
62
+ : false;
63
+ };
64
+ export const _patternIdSchemaKeysMatch: Record<PatternId["kind"], true> =
65
+ null as unknown as PatternIdSchemaKeysMatch;
66
+ export const _patternIdSchemaAssignable: Record<PatternId["kind"], true> =
67
+ null as unknown as PatternIdSchemaAssignable;
68
+
69
+ const REPO_ROOT = resolve(__dirname, "../../../../../..");
70
+
71
+ const REAL_FEATURE_PATHS: readonly string[] = [
72
+ "packages/bundled-features/src/tenant/feature.ts",
73
+ "packages/bundled-features/src/audit/feature.ts",
74
+ "packages/bundled-features/src/sessions/feature.ts",
75
+ "packages/bundled-features/src/auth-email-password/feature.ts",
76
+ ];
77
+
78
+ function collectRecipeFeaturePaths(): readonly string[] {
79
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
80
+ const matches = project
81
+ .addSourceFilesAtPaths(resolve(REPO_ROOT, "samples/recipes/**/src/feature.ts"))
82
+ .map((sf) => sf.getFilePath());
83
+ return matches;
84
+ }
85
+
86
+ describe("parsePatternChanges — round-trip guard against real features", () => {
87
+ const allPaths = [
88
+ ...REAL_FEATURE_PATHS.map((p) => resolve(REPO_ROOT, p)),
89
+ ...collectRecipeFeaturePaths(),
90
+ ];
91
+
92
+ for (const path of allPaths) {
93
+ test(`every parsed pattern in ${path.replace(`${REPO_ROOT}/`, "")} round-trips through parsePatternChanges`, () => {
94
+ const result = parseFeatureFile(path);
95
+ for (const pattern of result.patterns) {
96
+ const parsed = parsePatternChanges([{ op: "add", pattern }]);
97
+ if (!parsed.ok) {
98
+ throw new Error(
99
+ `pattern kind=${pattern.kind} failed to round-trip: ${JSON.stringify(parsed.issues)}\n` +
100
+ `pattern: ${JSON.stringify(pattern)}`,
101
+ );
102
+ }
103
+ expect(parsed.changes).toEqual([{ op: "add", pattern }]);
104
+ }
105
+ });
106
+ }
107
+ });
108
+
109
+ describe("parsePatternChanges — structural validation", () => {
110
+ test("non-array input is rejected with path 'changes'", () => {
111
+ const result = parsePatternChanges({ not: "an array" });
112
+ expect(result.ok).toBe(false);
113
+ if (result.ok) return;
114
+ expect(result.issues).toEqual([{ path: "changes", message: "must be an array" }]);
115
+ });
116
+
117
+ test("nested access under a stray `definition` key is rejected with the exact contract path", () => {
118
+ const result = parsePatternChanges([
119
+ {
120
+ op: "replace",
121
+ id: { kind: "writeHandler", handlerName: "customer:delete" },
122
+ pattern: {
123
+ kind: "writeHandler",
124
+ handlerName: "customer:delete",
125
+ schemaSource: "z.object({})",
126
+ handlerBody: "async () => {}",
127
+ definition: { access: { roles: ["TenantAdmin"] } },
128
+ },
129
+ },
130
+ ]);
131
+ expect(result.ok).toBe(false);
132
+ if (result.ok) return;
133
+ expect(result.issues).toContainEqual({
134
+ path: "changes[0].pattern.definition.access",
135
+ message: "unexpected key; access belongs at top level of the pattern",
136
+ });
137
+ });
138
+
139
+ test("a stray `definition.access` on a kind without top-level access gets the plain unexpected-key issue", () => {
140
+ const result = parsePatternChanges([
141
+ {
142
+ op: "add",
143
+ pattern: {
144
+ kind: "hook",
145
+ hookType: "postSave",
146
+ target: "customer:create",
147
+ fnBody: "async () => {}",
148
+ definition: { access: { roles: ["TenantAdmin"] } },
149
+ },
150
+ },
151
+ ]);
152
+ expect(result.ok).toBe(false);
153
+ if (result.ok) return;
154
+ expect(result.issues).toEqual([
155
+ { path: "changes[0].pattern.definition", message: "unexpected key" },
156
+ ]);
157
+ });
158
+
159
+ test("F11-form partial replace (access only, no schema/handler body) is rejected at both paths", () => {
160
+ const result = parsePatternChanges([
161
+ {
162
+ op: "replace",
163
+ id: { kind: "writeHandler", handlerName: "customer:delete" },
164
+ pattern: {
165
+ kind: "writeHandler",
166
+ handlerName: "customer:delete",
167
+ access: { roles: ["TenantAdmin"] },
168
+ },
169
+ },
170
+ ]);
171
+ expect(result.ok).toBe(false);
172
+ if (result.ok) return;
173
+ const paths = result.issues.map((i) => i.path);
174
+ expect(paths).toContain("changes[0].pattern.schemaSource");
175
+ expect(paths).toContain("changes[0].pattern.handlerBody");
176
+ });
177
+
178
+ test("malformed access rules are rejected", () => {
179
+ const base = {
180
+ kind: "writeHandler" as const,
181
+ handlerName: "x",
182
+ schemaSource: "z.object({})",
183
+ handlerBody: "async () => {}",
184
+ };
185
+ const whitespaceReason = parsePatternChanges([
186
+ { op: "add", pattern: { ...base, access: { openToAll: { reason: " " } } } },
187
+ ]);
188
+ expect(whitespaceReason.ok).toBe(false);
189
+ if (!whitespaceReason.ok) {
190
+ expect(whitespaceReason.issues).toContainEqual(
191
+ expect.objectContaining({ path: "changes[0].pattern.access.openToAll.reason" }),
192
+ );
193
+ }
194
+
195
+ const wrongRolesType = parsePatternChanges([
196
+ { op: "add", pattern: { ...base, access: { roles: "Admin" } } },
197
+ ]);
198
+ expect(wrongRolesType.ok).toBe(false);
199
+ if (!wrongRolesType.ok) {
200
+ expect(
201
+ wrongRolesType.issues.some((i) => i.path.startsWith("changes[0].pattern.access")),
202
+ ).toBe(true);
203
+ }
204
+
205
+ const extraKey = parsePatternChanges([
206
+ { op: "add", pattern: { ...base, access: { roles: ["Admin"], extra: true } } },
207
+ ]);
208
+ expect(extraKey.ok).toBe(false);
209
+ if (!extraKey.ok) {
210
+ expect(extraKey.issues).toContainEqual(
211
+ expect.objectContaining({ path: "changes[0].pattern.access" }),
212
+ );
213
+ }
214
+ });
215
+
216
+ test("entity field-type validation reports the exact path", () => {
217
+ const result = parsePatternChanges([
218
+ {
219
+ op: "add",
220
+ pattern: {
221
+ kind: "entity",
222
+ entityName: "invoice",
223
+ definition: { fields: { payload: { type: "json" } } },
224
+ },
225
+ },
226
+ ]);
227
+ expect(result.ok).toBe(false);
228
+ if (result.ok) return;
229
+ expect(result.issues).toContainEqual(
230
+ expect.objectContaining({ path: "changes[0].pattern.definition.fields.payload.type" }),
231
+ );
232
+ });
233
+
234
+ test("an opaque handler reference with a set header key is rejected (header would be silently dropped)", () => {
235
+ const result = parsePatternChanges([
236
+ {
237
+ op: "add",
238
+ pattern: {
239
+ kind: "writeHandler",
240
+ source: "someRef",
241
+ access: { roles: ["Admin"] },
242
+ },
243
+ },
244
+ ]);
245
+ expect(result.ok).toBe(false);
246
+ if (result.ok) return;
247
+ expect(result.issues).toContainEqual(
248
+ expect.objectContaining({ path: "changes[0].pattern.access" }),
249
+ );
250
+ });
251
+
252
+ test("id.kind / pattern.kind mismatch is rejected", () => {
253
+ const result = parsePatternChanges([
254
+ {
255
+ op: "replace",
256
+ id: { kind: "metric", shortName: "created_total" },
257
+ pattern: { kind: "secret", shortName: "created_total", options: {} },
258
+ },
259
+ ]);
260
+ expect(result.ok).toBe(false);
261
+ if (result.ok) return;
262
+ expect(result.issues).toContainEqual(
263
+ expect.objectContaining({ path: "changes[0].pattern.kind" }),
264
+ );
265
+ });
266
+
267
+ test("unknown op is rejected", () => {
268
+ const result = parsePatternChanges([{ op: "upsert", pattern: { kind: "systemScope" } }]);
269
+ expect(result.ok).toBe(false);
270
+ });
271
+
272
+ test("uiHints/unknown patterns without a non-empty source.raw are rejected", () => {
273
+ const uiHints = parsePatternChanges([{ op: "add", pattern: { kind: "uiHints", source: "" } }]);
274
+ expect(uiHints.ok).toBe(false);
275
+
276
+ const unknownKind = parsePatternChanges([
277
+ { op: "add", pattern: { kind: "unknown", methodName: "r.somethingNew", source: "" } },
278
+ ]);
279
+ expect(unknownKind.ok).toBe(false);
280
+ });
281
+
282
+ test("rationale is accepted on the wire and dropped from the parsed output", () => {
283
+ const result = parsePatternChanges([
284
+ {
285
+ op: "add",
286
+ rationale: "operator asked for this",
287
+ pattern: { kind: "systemScope" },
288
+ },
289
+ ]);
290
+ expect(result.ok).toBe(true);
291
+ if (!result.ok) return;
292
+ expect(result.changes[0]).toEqual({
293
+ op: "add",
294
+ pattern: { kind: "systemScope", source: expect.any(Object) },
295
+ });
296
+ expect(result.changes[0]).not.toHaveProperty("rationale");
297
+ });
298
+ });
299
+
300
+ describe("parsePatternChanges — extractEntity uses the same field-type catalogue", () => {
301
+ const STARTER = (fieldExpr: string) => `
302
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
303
+
304
+ defineFeature("billing", (r) => {
305
+ r.entity("invoice", { fields: { payload: ${fieldExpr} } });
306
+ });
307
+ `;
308
+
309
+ let fileCounter = 0;
310
+ function makeSourceFile(content: string): SourceFile {
311
+ const project = new Project({
312
+ skipAddingFilesFromTsConfig: true,
313
+ skipFileDependencyResolution: true,
314
+ useInMemoryFileSystem: true,
315
+ });
316
+ fileCounter += 1;
317
+ return project.createSourceFile(`f-${fileCounter}.ts`, content);
318
+ }
319
+
320
+ test("unknown field type produces a ParseError with the expected message", () => {
321
+ const result = parseSourceFile(makeSourceFile(STARTER('{ type: "json" }')));
322
+ const error = result.errors.find((e) => e.methodName === "entity");
323
+ expect(error).toBeDefined();
324
+ expect(error?.reason).toContain('definition.fields.payload.type: unknown field type "json"');
325
+ });
326
+
327
+ test("unknown field type in object-form r.entity(...) also produces a ParseError", () => {
328
+ const source = `
329
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
330
+
331
+ defineFeature("billing", (r) => {
332
+ r.entity({ name: "invoice", fields: { payload: { type: "json" } } });
333
+ });
334
+ `;
335
+ const result = parseSourceFile(makeSourceFile(source));
336
+ const error = result.errors.find((e) => e.methodName === "entity");
337
+ expect(error).toBeDefined();
338
+ expect(error?.reason).toContain('definition.fields.payload.type: unknown field type "json"');
339
+ });
340
+
341
+ test("a real field type (jsonb) does not error", () => {
342
+ const result = parseSourceFile(makeSourceFile(STARTER('{ type: "jsonb" }')));
343
+ expect(result.errors.find((e) => e.methodName === "entity")).toBeUndefined();
344
+ });
345
+
346
+ test("an unresolvable identifier field-type sentinel does not error and still extracts the entity", () => {
347
+ const source = `
348
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
349
+ import { SOME_TYPE } from "./somewhere";
350
+
351
+ defineFeature("billing", (r) => {
352
+ r.entity("invoice", { fields: { payload: { type: SOME_TYPE } } });
353
+ });
354
+ `;
355
+ const result = parseSourceFile(makeSourceFile(source));
356
+ expect(result.errors.find((e) => e.methodName === "entity")).toBeUndefined();
357
+ const entity = result.patterns.find(
358
+ (p): p is Extract<FeaturePattern, { kind: "entity" }> => p.kind === "entity",
359
+ );
360
+ expect(entity).toBeDefined();
361
+ expect(entity?.entityName).toBe("invoice");
362
+ });
363
+ });
364
+
365
+ describe("parsePatternChanges — applyChanges integration for opaque bodies", () => {
366
+ const STARTER = `
367
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
368
+
369
+ defineFeature("inventory", (r) => {
370
+ });
371
+ `;
372
+
373
+ let fileCounter = 0;
374
+ function makeSourceFile(content: string): SourceFile {
375
+ const project = new Project({
376
+ skipAddingFilesFromTsConfig: true,
377
+ skipFileDependencyResolution: true,
378
+ useInMemoryFileSystem: true,
379
+ });
380
+ fileCounter += 1;
381
+ return project.createSourceFile(`f-${fileCounter}.ts`, content);
382
+ }
383
+
384
+ test("string-form bodies re-parse with identical handlerName/access/raw text", () => {
385
+ const parsed = parsePatternChanges([
386
+ {
387
+ op: "add",
388
+ pattern: {
389
+ kind: "writeHandler",
390
+ handlerName: "task:create",
391
+ access: { roles: ["Admin"] },
392
+ schemaSource: "z.object({ title: z.string() })",
393
+ handlerBody: "async (event, ctx) => { return { ok: true }; }",
394
+ },
395
+ },
396
+ ]);
397
+ expect(parsed.ok).toBe(true);
398
+ if (!parsed.ok) return;
399
+
400
+ const sf = makeSourceFile(STARTER);
401
+ applyChanges(sf, parsed.changes);
402
+ const reparsed = parseSourceFile(sf);
403
+ const handler = reparsed.patterns.find(
404
+ (p): p is Extract<FeaturePattern, { kind: "writeHandler" }> => p.kind === "writeHandler",
405
+ );
406
+ expect(handler?.handlerName).toBe("task:create");
407
+ expect(handler?.access).toEqual({ roles: ["Admin"] });
408
+ expect(handler?.schemaSource?.raw).toBe("z.object({ title: z.string() })");
409
+ expect(handler?.handlerBody?.raw).toBe("async (event, ctx) => { return { ok: true }; }");
410
+ });
411
+
412
+ test("{raw} object-form bodies re-parse with identical handlerName/access/raw text", () => {
413
+ const parsed = parsePatternChanges([
414
+ {
415
+ op: "add",
416
+ pattern: {
417
+ kind: "writeHandler",
418
+ handlerName: "task:archive",
419
+ access: { openToAll: { reason: "internal tool" } },
420
+ schemaSource: { raw: "z.object({ id: z.string() })" },
421
+ handlerBody: { raw: "async (event, ctx) => { return { ok: true }; }" },
422
+ },
423
+ },
424
+ ]);
425
+ expect(parsed.ok).toBe(true);
426
+ if (!parsed.ok) return;
427
+
428
+ const sf = makeSourceFile(STARTER);
429
+ applyChanges(sf, parsed.changes);
430
+ const reparsed = parseSourceFile(sf);
431
+ const handler = reparsed.patterns.find(
432
+ (p): p is Extract<FeaturePattern, { kind: "writeHandler" }> => p.kind === "writeHandler",
433
+ );
434
+ expect(handler?.handlerName).toBe("task:archive");
435
+ expect(handler?.access).toEqual({ openToAll: { reason: "internal tool" } });
436
+ expect(handler?.schemaSource?.raw).toBe("z.object({ id: z.string() })");
437
+ expect(handler?.handlerBody?.raw).toBe("async (event, ctx) => { return { ok: true }; }");
438
+ });
439
+ });
440
+
441
+ describe('parsePatternChanges — op: "update"', () => {
442
+ test("F11-form: set access only, no schema/handler body", () => {
443
+ const result = parsePatternChanges([
444
+ {
445
+ op: "update",
446
+ id: { kind: "writeHandler", handlerName: "x" },
447
+ set: { access: { roles: ["TenantAdmin"] } },
448
+ },
449
+ ]);
450
+ expect(result.ok).toBe(true);
451
+ if (!result.ok) throw new Error("expected an ok result");
452
+ expect(result.changes).toEqual([
453
+ {
454
+ op: "update",
455
+ id: { kind: "writeHandler", handlerName: "x" },
456
+ set: { access: { roles: ["TenantAdmin"] } },
457
+ },
458
+ ]);
459
+ });
460
+
461
+ test("set.handlerBody / set.schemaSource are not header fields — rejected", () => {
462
+ const result = parsePatternChanges([
463
+ {
464
+ op: "update",
465
+ id: { kind: "writeHandler", handlerName: "x" },
466
+ set: { handlerBody: "async () => {}", schemaSource: "z.object({})" },
467
+ },
468
+ ]);
469
+ expect(result.ok).toBe(false);
470
+ if (result.ok) throw new Error("expected an error result");
471
+ const paths = result.issues.map((i) => i.path);
472
+ expect(paths).toContain("changes[0].set.handlerBody");
473
+ expect(paths).toContain("changes[0].set.schemaSource");
474
+ });
475
+
476
+ test("id.kind not writeHandler/queryHandler/streamHandler is rejected at id.kind", () => {
477
+ const result = parsePatternChanges([
478
+ { op: "update", id: { kind: "entity", entityName: "item" }, set: { access: {} } },
479
+ ]);
480
+ expect(result.ok).toBe(false);
481
+ if (result.ok) throw new Error("expected an error result");
482
+ expect(result.issues).toContainEqual(expect.objectContaining({ path: "changes[0].id.kind" }));
483
+ });
484
+
485
+ test("overlap between set and unset is rejected at unset[j]", () => {
486
+ const result = parsePatternChanges([
487
+ {
488
+ op: "update",
489
+ id: { kind: "writeHandler", handlerName: "x" },
490
+ set: { description: "new" },
491
+ unset: ["description"],
492
+ },
493
+ ]);
494
+ expect(result.ok).toBe(false);
495
+ if (result.ok) throw new Error("expected an error result");
496
+ expect(result.issues).toContainEqual(
497
+ expect.objectContaining({ path: "changes[0].unset[0]", message: "key is also in set" }),
498
+ );
499
+ });
500
+
501
+ test("empty set and unset is rejected at changes[i].set", () => {
502
+ const result = parsePatternChanges([
503
+ { op: "update", id: { kind: "writeHandler", handlerName: "x" } },
504
+ ]);
505
+ expect(result.ok).toBe(false);
506
+ if (result.ok) throw new Error("expected an error result");
507
+ expect(result.issues).toContainEqual(expect.objectContaining({ path: "changes[0].set" }));
508
+ });
509
+
510
+ test('unset: ["access"] is rejected — access is required', () => {
511
+ const result = parsePatternChanges([
512
+ { op: "update", id: { kind: "writeHandler", handlerName: "x" }, unset: ["access"] },
513
+ ]);
514
+ expect(result.ok).toBe(false);
515
+ if (result.ok) throw new Error("expected an error result");
516
+ expect(result.issues).toContainEqual(
517
+ expect.objectContaining({
518
+ path: "changes[0].unset[0]",
519
+ message: "access is required and cannot be unset",
520
+ }),
521
+ );
522
+ });
523
+
524
+ test("invalid access value is rejected at changes[i].set.access...", () => {
525
+ const result = parsePatternChanges([
526
+ {
527
+ op: "update",
528
+ id: { kind: "writeHandler", handlerName: "x" },
529
+ set: { access: { roles: "Admin" } },
530
+ },
531
+ ]);
532
+ expect(result.ok).toBe(false);
533
+ if (result.ok) throw new Error("expected an error result");
534
+ expect(result.issues.some((i) => i.path.startsWith("changes[0].set.access"))).toBe(true);
535
+ });
536
+
537
+ test("streamHandler rejects set.description — not a streamHandler header field", () => {
538
+ const result = parsePatternChanges([
539
+ {
540
+ op: "update",
541
+ id: { kind: "streamHandler", handlerName: "x" },
542
+ set: { description: "not allowed" },
543
+ },
544
+ ]);
545
+ expect(result.ok).toBe(false);
546
+ if (result.ok) throw new Error("expected an error result");
547
+ expect(result.issues).toContainEqual(
548
+ expect.objectContaining({ path: "changes[0].set.description" }),
549
+ );
550
+ });
551
+
552
+ // update.set stays as strict as the extractor's structured output: a
553
+ // Designer/AI-authored value must be a literal, never a raw reference or
554
+ // a header shape that only ever comes from parsed source.
555
+ test("set.access as a raw sentinel is rejected", () => {
556
+ const result = parsePatternChanges([
557
+ {
558
+ op: "update",
559
+ id: { kind: "writeHandler", handlerName: "x" },
560
+ set: { access: { __raw: "ADMIN" } },
561
+ },
562
+ ]);
563
+ expect(result.ok).toBe(false);
564
+ if (result.ok) throw new Error("expected an error result");
565
+ expect(result.issues.some((i) => i.path.startsWith("changes[0].set.access"))).toBe(true);
566
+ });
567
+
568
+ test("set.rateLimit as a disabled shape is rejected", () => {
569
+ const result = parsePatternChanges([
570
+ {
571
+ op: "update",
572
+ id: { kind: "writeHandler", handlerName: "x" },
573
+ set: { rateLimit: { disabled: true, reason: "x" } },
574
+ },
575
+ ]);
576
+ expect(result.ok).toBe(false);
577
+ if (result.ok) throw new Error("expected an error result");
578
+ expect(result.issues.some((i) => i.path.startsWith("changes[0].set.rateLimit"))).toBe(true);
579
+ });
580
+
581
+ test("end-to-end: parsePatternChanges → applyChanges → re-parse", () => {
582
+ const starter = `
583
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
584
+
585
+ defineFeature("inventory", (r) => {
586
+ r.writeHandler({
587
+ name: "item:create",
588
+ schema: z.object({}),
589
+ handler: async () => {},
590
+ access: { openToAll: { reason: "test" } },
591
+ });
592
+ });
593
+ `;
594
+ const parsed = parsePatternChanges([
595
+ {
596
+ op: "update",
597
+ id: { kind: "writeHandler", handlerName: "item:create" },
598
+ set: { access: { roles: ["TenantAdmin"] } },
599
+ },
600
+ ]);
601
+ expect(parsed.ok).toBe(true);
602
+ if (!parsed.ok) throw new Error("expected an ok result");
603
+
604
+ const project = new Project({
605
+ skipAddingFilesFromTsConfig: true,
606
+ skipFileDependencyResolution: true,
607
+ useInMemoryFileSystem: true,
608
+ });
609
+ const sf = project.createSourceFile("e2e.ts", starter);
610
+ applyChanges(sf, parsed.changes);
611
+ const reparsed = parseSourceFile(sf);
612
+ expect(reparsed.errors).toEqual([]);
613
+ expect(reparsed.patterns.find((p) => p.kind === "writeHandler")).toMatchObject({
614
+ access: { roles: ["TenantAdmin"] },
615
+ });
616
+ });
617
+ });
618
+
619
+ describe("escapeHatch.reason must be non-empty after trim", () => {
620
+ test("update rejects a whitespace-only reason", () => {
621
+ const result = parsePatternChanges([
622
+ {
623
+ op: "update",
624
+ id: { kind: "writeHandler", handlerName: "x" },
625
+ set: { escapeHatch: { reason: " " } },
626
+ },
627
+ ]);
628
+ expect(result.ok).toBe(false);
629
+ if (result.ok) throw new Error("expected an error result");
630
+ expect(result.issues.some((i) => i.path.startsWith("changes[0].set.escapeHatch"))).toBe(true);
631
+ });
632
+
633
+ test("add rejects a whitespace-only reason", () => {
634
+ const result = parsePatternChanges([
635
+ {
636
+ op: "add",
637
+ pattern: {
638
+ kind: "writeHandler",
639
+ handlerName: "x",
640
+ schemaSource: "z.object({})",
641
+ handlerBody: "async () => {}",
642
+ escapeHatch: { reason: " " },
643
+ },
644
+ },
645
+ ]);
646
+ expect(result.ok).toBe(false);
647
+ if (result.ok) throw new Error("expected an error result");
648
+ expect(result.issues.some((i) => i.path === "changes[0].pattern.escapeHatch.reason")).toBe(
649
+ true,
650
+ );
651
+ });
652
+
653
+ test("replace rejects a whitespace-only reason", () => {
654
+ const result = parsePatternChanges([
655
+ {
656
+ op: "replace",
657
+ id: { kind: "writeHandler", handlerName: "x" },
658
+ pattern: {
659
+ kind: "writeHandler",
660
+ handlerName: "x",
661
+ schemaSource: "z.object({})",
662
+ handlerBody: "async () => {}",
663
+ escapeHatch: { reason: "\t" },
664
+ },
665
+ },
666
+ ]);
667
+ expect(result.ok).toBe(false);
668
+ if (result.ok) throw new Error("expected an error result");
669
+ expect(result.issues.some((i) => i.path === "changes[0].pattern.escapeHatch.reason")).toBe(
670
+ true,
671
+ );
672
+ });
673
+ });
674
+
675
+ describe("round-trip guard: handler headers authored as references", () => {
676
+ const SOURCE = `
677
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
678
+ import { z } from "zod";
679
+ import { ADMIN } from "./access-consts";
680
+ import { ROLE_X } from "./roles";
681
+ import { REASON } from "./reasons";
682
+
683
+ const LOCAL = { roles: ["Admin"] };
684
+
685
+ defineFeature("f", (r) => {
686
+ r.writeHandler({
687
+ name: "x",
688
+ schema: z.object({}),
689
+ handler: async () => {},
690
+ access: ADMIN,
691
+ rateLimit: { disabled: true, reason: "x" },
692
+ });
693
+
694
+ r.queryHandler({
695
+ name: "y",
696
+ schema: z.object({}),
697
+ handler: async () => ({}),
698
+ access: { roles: [ROLE_X] },
699
+ escapeHatch: { reason: REASON },
700
+ });
701
+
702
+ r.streamHandler({
703
+ name: "z",
704
+ schema: z.object({}),
705
+ handler: async function* () { yield "token"; },
706
+ access: LOCAL,
707
+ escapeHatch: { reason: "r" },
708
+ });
709
+
710
+ r.hook("postSave", "z", async (event, ctx) => {}, { escapeHatch: { reason: REASON } });
711
+ });
712
+ `;
713
+
714
+ let fileCounter = 0;
715
+ function makeSourceFile(content: string): SourceFile {
716
+ const project = new Project({
717
+ skipAddingFilesFromTsConfig: true,
718
+ skipFileDependencyResolution: true,
719
+ useInMemoryFileSystem: true,
720
+ });
721
+ fileCounter += 1;
722
+ return project.createSourceFile(`f-${fileCounter}.ts`, content);
723
+ }
724
+
725
+ test("every handler pattern round-trips through parsePatternChanges", () => {
726
+ const result = parseSourceFile(makeSourceFile(SOURCE));
727
+ expect(result.errors).toEqual([]);
728
+ const handlers = result.patterns.filter(
729
+ (p) =>
730
+ p.kind === "writeHandler" ||
731
+ p.kind === "queryHandler" ||
732
+ p.kind === "streamHandler" ||
733
+ p.kind === "hook",
734
+ );
735
+ expect(handlers).toHaveLength(4);
736
+ for (const pattern of handlers) {
737
+ const parsed = parsePatternChanges([{ op: "add", pattern }]);
738
+ expect(parsed.ok).toBe(true);
739
+ if (!parsed.ok) continue;
740
+ expect(parsed.changes).toEqual([{ op: "add", pattern }]);
741
+ }
742
+ });
743
+
744
+ test("a raw sentinel with an extra key is rejected", () => {
745
+ const result = parsePatternChanges([
746
+ {
747
+ op: "add",
748
+ pattern: {
749
+ kind: "writeHandler",
750
+ handlerName: "task:create",
751
+ access: { __raw: "X", extra: 1 },
752
+ schemaSource: "z.object({})",
753
+ handlerBody: "async () => {}",
754
+ },
755
+ },
756
+ ]);
757
+ expect(result.ok).toBe(false);
758
+ });
759
+
760
+ test("a rateLimit disabled shape without reason is rejected", () => {
761
+ const result = parsePatternChanges([
762
+ {
763
+ op: "add",
764
+ pattern: {
765
+ kind: "writeHandler",
766
+ handlerName: "task:create",
767
+ rateLimit: { disabled: true },
768
+ schemaSource: "z.object({})",
769
+ handlerBody: "async () => {}",
770
+ },
771
+ },
772
+ ]);
773
+ expect(result.ok).toBe(false);
774
+ });
775
+
776
+ test("a rateLimit disabled shape with an empty reason is rejected", () => {
777
+ const result = parsePatternChanges([
778
+ {
779
+ op: "add",
780
+ pattern: {
781
+ kind: "writeHandler",
782
+ handlerName: "task:create",
783
+ rateLimit: { disabled: true, reason: " " },
784
+ schemaSource: "z.object({})",
785
+ handlerBody: "async () => {}",
786
+ },
787
+ },
788
+ ]);
789
+ expect(result.ok).toBe(false);
790
+ });
791
+
792
+ test("an opaque handler pattern (top-level spread) round-trips through parsePatternChanges", () => {
793
+ const source = `
794
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
795
+ import { z } from "zod";
796
+
797
+ const BASE = { description: "shared" };
798
+
799
+ defineFeature("f", (r) => {
800
+ r.writeHandler({
801
+ ...BASE,
802
+ name: "x",
803
+ schema: z.object({}),
804
+ handler: async () => {},
805
+ access: { roles: ["Admin"] },
806
+ });
807
+ });
808
+ `;
809
+ const result = parseSourceFile(makeSourceFile(source));
810
+ expect(result.errors).toEqual([]);
811
+ const pattern = result.patterns.find((p) => p.kind === "writeHandler");
812
+ if (!pattern) throw new Error("no writeHandler pattern found");
813
+ expect(pattern.handlerName).toBeUndefined();
814
+ const parsed = parsePatternChanges([{ op: "add", pattern }]);
815
+ expect(parsed.ok).toBe(true);
816
+ if (!parsed.ok) return;
817
+ expect(parsed.changes).toEqual([{ op: "add", pattern }]);
818
+ });
819
+ });