@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,718 @@
1
+ // `updatePattern`/`op: "update"` tests: a header-field-only edit must leave
2
+ // schemaSource/handlerBody/comments and every unnamed field byte-identical.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { Project, type SourceFile } from "ts-morph";
6
+ import { parseSourceFile } from "../parse";
7
+ import { applyChanges, type HandlerHeaderUpdate, updatePattern } from "../patch";
8
+
9
+ let fileCounter = 0;
10
+
11
+ function makeSourceFile(content: string): SourceFile {
12
+ const project = new Project({
13
+ skipAddingFilesFromTsConfig: true,
14
+ skipFileDependencyResolution: true,
15
+ useInMemoryFileSystem: true,
16
+ });
17
+ fileCounter++;
18
+ return project.createSourceFile(`f-${fileCounter}.ts`, content);
19
+ }
20
+
21
+ function syntaxErrors(sf: SourceFile): readonly string[] {
22
+ return sf
23
+ .getProject()
24
+ .getProgram()
25
+ .getSyntacticDiagnostics(sf)
26
+ .map((d) => d.getMessageText().toString());
27
+ }
28
+
29
+ describe("updatePattern — writeHandler", () => {
30
+ test("F11: set access, schema/handler body byte-identical, no diagnostics", () => {
31
+ const starter = `
32
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
33
+
34
+ defineFeature("inventory", (r) => {
35
+ r.writeHandler({
36
+ name: "item:create",
37
+ schema: z.object({}),
38
+ handler: async () => {},
39
+ access: { openToAll: { reason: "test" } },
40
+ });
41
+ });
42
+ `;
43
+ const sf = makeSourceFile(starter);
44
+ const change: HandlerHeaderUpdate = {
45
+ op: "update",
46
+ id: { kind: "writeHandler", handlerName: "item:create" },
47
+ set: { access: { roles: ["TenantAdmin"] } },
48
+ };
49
+ updatePattern(sf, change);
50
+
51
+ const expected = `
52
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
53
+
54
+ defineFeature("inventory", (r) => {
55
+ r.writeHandler({
56
+ name: "item:create",
57
+ schema: z.object({}),
58
+ handler: async () => {},
59
+ access: { roles: ["TenantAdmin"] },
60
+ });
61
+ });
62
+ `;
63
+ expect(sf.getFullText()).toBe(expected);
64
+ expect(syntaxErrors(sf)).toEqual([]);
65
+
66
+ const reparsed = parseSourceFile(sf);
67
+ expect(reparsed.errors).toEqual([]);
68
+ const pattern = reparsed.patterns.find((p) => p.kind === "writeHandler");
69
+ expect(pattern).toMatchObject({ access: { roles: ["TenantAdmin"] } });
70
+ expect(pattern?.kind).toBe("writeHandler");
71
+ if (pattern?.kind !== "writeHandler") throw new Error("expected a writeHandler pattern");
72
+ expect(pattern.schemaSource?.raw).toBe("z.object({})");
73
+ expect(pattern.handlerBody?.raw).toBe("async () => {}");
74
+ });
75
+
76
+ test("rateLimit update leaves a non-literal `access: ADMIN` and its trailing comment untouched", () => {
77
+ const starter = `
78
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
79
+
80
+ const ADMIN = { roles: ["Admin"] };
81
+
82
+ defineFeature("inventory", (r) => {
83
+ r.writeHandler({
84
+ name: "item:archive",
85
+ schema: z.object({}),
86
+ handler: async () => {},
87
+ access: ADMIN, // legacy role set
88
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
89
+ });
90
+ });
91
+ `;
92
+ const sf = makeSourceFile(starter);
93
+ const change: HandlerHeaderUpdate = {
94
+ op: "update",
95
+ id: { kind: "writeHandler", handlerName: "item:archive" },
96
+ set: { rateLimit: { per: "user", limit: 10, windowSeconds: 120 } },
97
+ };
98
+ updatePattern(sf, change);
99
+
100
+ const expected = `
101
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
102
+
103
+ const ADMIN = { roles: ["Admin"] };
104
+
105
+ defineFeature("inventory", (r) => {
106
+ r.writeHandler({
107
+ name: "item:archive",
108
+ schema: z.object({}),
109
+ handler: async () => {},
110
+ access: ADMIN, // legacy role set
111
+ rateLimit: { per: "user", limit: 10, windowSeconds: 120 },
112
+ });
113
+ });
114
+ `;
115
+ expect(sf.getFullText()).toBe(expected);
116
+ expect(syntaxErrors(sf)).toEqual([]);
117
+ });
118
+
119
+ test("appends a missing property (description) after the last existing property", () => {
120
+ const starter = `
121
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
122
+
123
+ defineFeature("inventory", (r) => {
124
+ r.writeHandler({
125
+ name: "item:create",
126
+ schema: z.object({}),
127
+ handler: async () => {},
128
+ access: { openToAll: { reason: "test" } },
129
+ });
130
+ });
131
+ `;
132
+ const sf = makeSourceFile(starter);
133
+ const change: HandlerHeaderUpdate = {
134
+ op: "update",
135
+ id: { kind: "writeHandler", handlerName: "item:create" },
136
+ set: { description: "Creates an item" },
137
+ };
138
+ updatePattern(sf, change);
139
+
140
+ const expected = `
141
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
142
+
143
+ defineFeature("inventory", (r) => {
144
+ r.writeHandler({
145
+ name: "item:create",
146
+ schema: z.object({}),
147
+ handler: async () => {},
148
+ access: { openToAll: { reason: "test" } },
149
+ description: "Creates an item",
150
+ });
151
+ });
152
+ `;
153
+ expect(sf.getFullText()).toBe(expected);
154
+ expect(syntaxErrors(sf)).toEqual([]);
155
+ });
156
+
157
+ test("shorthand `{ ..., rateLimit }` becomes `rateLimit: {...}`", () => {
158
+ const starter = `
159
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
160
+
161
+ const rateLimit = { per: "user", limit: 5, windowSeconds: 60 };
162
+
163
+ defineFeature("inventory", (r) => {
164
+ r.writeHandler({
165
+ name: "item:create",
166
+ schema: z.object({}),
167
+ handler: async () => {},
168
+ access: { openToAll: { reason: "test" } },
169
+ rateLimit,
170
+ });
171
+ });
172
+ `;
173
+ const sf = makeSourceFile(starter);
174
+ const change: HandlerHeaderUpdate = {
175
+ op: "update",
176
+ id: { kind: "writeHandler", handlerName: "item:create" },
177
+ set: { rateLimit: { per: "tenant", limit: 20, windowSeconds: 300 } },
178
+ };
179
+ updatePattern(sf, change);
180
+
181
+ expect(sf.getFullText()).toContain(
182
+ ' rateLimit: { per: "tenant", limit: 20, windowSeconds: 300 },',
183
+ );
184
+ expect(sf.getFullText()).not.toMatch(/\n\s*rateLimit,\n/);
185
+ expect(syntaxErrors(sf)).toEqual([]);
186
+ });
187
+
188
+ test("a long value that wraps multi-line is indented at the property's own column", () => {
189
+ const starter = `
190
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
191
+
192
+ defineFeature("inventory", (r) => {
193
+ r.writeHandler({
194
+ name: "item:create",
195
+ schema: z.object({}),
196
+ handler: async () => {},
197
+ access: { openToAll: { reason: "test" } },
198
+ });
199
+ });
200
+ `;
201
+ const sf = makeSourceFile(starter);
202
+ const change: HandlerHeaderUpdate = {
203
+ op: "update",
204
+ id: { kind: "writeHandler", handlerName: "item:create" },
205
+ set: {
206
+ access: {
207
+ roles: [
208
+ "TenantAdmin",
209
+ "TenantBillingManager",
210
+ "TenantSupportAgent",
211
+ "TenantComplianceReviewer",
212
+ ],
213
+ },
214
+ },
215
+ };
216
+ updatePattern(sf, change);
217
+
218
+ const expected = `
219
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
220
+
221
+ defineFeature("inventory", (r) => {
222
+ r.writeHandler({
223
+ name: "item:create",
224
+ schema: z.object({}),
225
+ handler: async () => {},
226
+ access: {
227
+ roles: [
228
+ "TenantAdmin",
229
+ "TenantBillingManager",
230
+ "TenantSupportAgent",
231
+ "TenantComplianceReviewer",
232
+ ],
233
+ },
234
+ });
235
+ });
236
+ `;
237
+ expect(sf.getFullText()).toBe(expected);
238
+ expect(syntaxErrors(sf)).toEqual([]);
239
+ });
240
+
241
+ test("appending after a property with a trailing comment keeps the comment on its own property's line", () => {
242
+ const starter = `
243
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
244
+
245
+ defineFeature("inventory", (r) => {
246
+ r.writeHandler({
247
+ name: "item:create",
248
+ schema: z.object({}),
249
+ handler: async () => {},
250
+ access: { openToAll: { reason: "test" } }, // legacy access rule
251
+ });
252
+ });
253
+ `;
254
+ const sf = makeSourceFile(starter);
255
+ const change: HandlerHeaderUpdate = {
256
+ op: "update",
257
+ id: { kind: "writeHandler", handlerName: "item:create" },
258
+ set: { description: "Creates an item" },
259
+ };
260
+ updatePattern(sf, change);
261
+
262
+ const expected = `
263
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
264
+
265
+ defineFeature("inventory", (r) => {
266
+ r.writeHandler({
267
+ name: "item:create",
268
+ schema: z.object({}),
269
+ handler: async () => {},
270
+ access: { openToAll: { reason: "test" } }, // legacy access rule
271
+ description: "Creates an item",
272
+ });
273
+ });
274
+ `;
275
+ expect(sf.getFullText()).toBe(expected);
276
+ expect(syntaxErrors(sf)).toEqual([]);
277
+ });
278
+
279
+ test("unset on a single-line literal removes only the target property, not the whole call", () => {
280
+ const starter = `
281
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
282
+
283
+ defineFeature("inventory", (r) => {
284
+ r.writeHandler({ name: "item:create", schema: z.object({}), handler: async () => {}, access: { openToAll: { reason: "test" } }, description: "old" });
285
+ });
286
+ `;
287
+ const sf = makeSourceFile(starter);
288
+ const change: HandlerHeaderUpdate = {
289
+ op: "update",
290
+ id: { kind: "writeHandler", handlerName: "item:create" },
291
+ set: {},
292
+ unset: ["description"],
293
+ };
294
+ updatePattern(sf, change);
295
+
296
+ const reparsed = parseSourceFile(sf);
297
+ expect(reparsed.errors).toEqual([]);
298
+ const pattern = reparsed.patterns.find((p) => p.kind === "writeHandler");
299
+ expect(pattern).toMatchObject({ handlerName: "item:create" });
300
+ expect(pattern?.kind).toBe("writeHandler");
301
+ if (pattern?.kind !== "writeHandler") throw new Error("expected a writeHandler pattern");
302
+ expect(pattern.description).toBeUndefined();
303
+ expect(pattern.schemaSource?.raw).toBe("z.object({})");
304
+ expect(pattern.handlerBody?.raw).toBe("async () => {}");
305
+ expect(syntaxErrors(sf)).toEqual([]);
306
+ });
307
+
308
+ test("unset removes the property line and its comma; unsetting a missing key is a no-op", () => {
309
+ const starter = `
310
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
311
+
312
+ defineFeature("inventory", (r) => {
313
+ r.writeHandler({
314
+ name: "item:create",
315
+ schema: z.object({}),
316
+ handler: async () => {},
317
+ access: { openToAll: { reason: "test" } },
318
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
319
+ });
320
+ });
321
+ `;
322
+ const sf = makeSourceFile(starter);
323
+ const change: HandlerHeaderUpdate = {
324
+ op: "update",
325
+ id: { kind: "writeHandler", handlerName: "item:create" },
326
+ set: {},
327
+ unset: ["rateLimit", "description"],
328
+ };
329
+ updatePattern(sf, change);
330
+
331
+ const expected = `
332
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
333
+
334
+ defineFeature("inventory", (r) => {
335
+ r.writeHandler({
336
+ name: "item:create",
337
+ schema: z.object({}),
338
+ handler: async () => {},
339
+ access: { openToAll: { reason: "test" } },
340
+ });
341
+ });
342
+ `;
343
+ expect(sf.getFullText()).toBe(expected);
344
+ expect(syntaxErrors(sf)).toEqual([]);
345
+ });
346
+
347
+ test("a duplicate key in `unset` does not corrupt code after the handler call", () => {
348
+ const starter = `
349
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
350
+
351
+ defineFeature("inventory", (r) => {
352
+ r.writeHandler({
353
+ name: "item:create",
354
+ schema: z.object({}),
355
+ handler: async () => {},
356
+ access: { openToAll: { reason: "test" } },
357
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
358
+ });
359
+
360
+ r.metric({ name: "created", type: "counter" });
361
+ });
362
+ `;
363
+ const sf = makeSourceFile(starter);
364
+ const change: HandlerHeaderUpdate = {
365
+ op: "update",
366
+ id: { kind: "writeHandler", handlerName: "item:create" },
367
+ set: {},
368
+ unset: ["rateLimit", "rateLimit"],
369
+ };
370
+ updatePattern(sf, change);
371
+
372
+ const expected = `
373
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
374
+
375
+ defineFeature("inventory", (r) => {
376
+ r.writeHandler({
377
+ name: "item:create",
378
+ schema: z.object({}),
379
+ handler: async () => {},
380
+ access: { openToAll: { reason: "test" } },
381
+ });
382
+
383
+ r.metric({ name: "created", type: "counter" });
384
+ });
385
+ `;
386
+ expect(sf.getFullText()).toBe(expected);
387
+ expect(syntaxErrors(sf)).toEqual([]);
388
+ });
389
+
390
+ test("a key present in both set and unset throws", () => {
391
+ const starter = `
392
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
393
+
394
+ defineFeature("inventory", (r) => {
395
+ r.writeHandler({
396
+ name: "item:create",
397
+ schema: z.object({}),
398
+ handler: async () => {},
399
+ access: { openToAll: { reason: "test" } },
400
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
401
+ });
402
+ });
403
+ `;
404
+ const sf = makeSourceFile(starter);
405
+ const change: HandlerHeaderUpdate = {
406
+ op: "update",
407
+ id: { kind: "writeHandler", handlerName: "item:create" },
408
+ set: { rateLimit: { per: "tenant", limit: 1, windowSeconds: 1 } },
409
+ unset: ["rateLimit"],
410
+ };
411
+ expect(() => updatePattern(sf, change)).toThrow(/is in both set and unset/);
412
+ });
413
+
414
+ test("appending after a value with no trailing comma but a trailing comment inserts the comma before the comment", () => {
415
+ const starter = `
416
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
417
+
418
+ defineFeature("inventory", (r) => {
419
+ r.writeHandler({
420
+ name: "item:create",
421
+ schema: z.object({}),
422
+ handler: async () => {},
423
+ access: { openToAll: { reason: "test" } } // no trailing comma, trailing comment
424
+ });
425
+ });
426
+ `;
427
+ const sf = makeSourceFile(starter);
428
+ const change: HandlerHeaderUpdate = {
429
+ op: "update",
430
+ id: { kind: "writeHandler", handlerName: "item:create" },
431
+ set: { description: "Creates an item" },
432
+ };
433
+ updatePattern(sf, change);
434
+
435
+ const expected = `
436
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
437
+
438
+ defineFeature("inventory", (r) => {
439
+ r.writeHandler({
440
+ name: "item:create",
441
+ schema: z.object({}),
442
+ handler: async () => {},
443
+ access: { openToAll: { reason: "test" } }, // no trailing comma, trailing comment
444
+ description: "Creates an item",
445
+ });
446
+ });
447
+ `;
448
+ expect(sf.getFullText()).toBe(expected);
449
+ expect(syntaxErrors(sf)).toEqual([]);
450
+ });
451
+
452
+ test("appending after a last property sharing its line with the closing `});` throws", () => {
453
+ const starter = `
454
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
455
+
456
+ defineFeature("inventory", (r) => {
457
+ r.writeHandler({
458
+ name: "item:create",
459
+ schema: z.object({}),
460
+ handler: async () => {},
461
+ access: { openToAll: { reason: "test" } } });
462
+ });
463
+ `;
464
+ const sf = makeSourceFile(starter);
465
+ const change: HandlerHeaderUpdate = {
466
+ op: "update",
467
+ id: { kind: "writeHandler", handlerName: "item:create" },
468
+ set: { description: "Creates an item" },
469
+ };
470
+ expect(() => updatePattern(sf, change)).toThrow(/shares a line with other code/);
471
+ });
472
+
473
+ test("two set keys + one unset key in a single change apply correctly (node invalidation)", () => {
474
+ const starter = `
475
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
476
+
477
+ defineFeature("inventory", (r) => {
478
+ r.writeHandler({
479
+ name: "item:create",
480
+ schema: z.object({}),
481
+ handler: async () => {},
482
+ access: { openToAll: { reason: "test" } },
483
+ description: "old description",
484
+ rateLimit: { per: "user", limit: 5, windowSeconds: 60 },
485
+ });
486
+ });
487
+ `;
488
+ const sf = makeSourceFile(starter);
489
+ const change: HandlerHeaderUpdate = {
490
+ op: "update",
491
+ id: { kind: "writeHandler", handlerName: "item:create" },
492
+ set: {
493
+ access: { roles: ["TenantAdmin"] },
494
+ description: "new description",
495
+ },
496
+ unset: ["rateLimit"],
497
+ };
498
+ updatePattern(sf, change);
499
+
500
+ const reparsed = parseSourceFile(sf);
501
+ expect(reparsed.errors).toEqual([]);
502
+ const pattern = reparsed.patterns.find((p) => p.kind === "writeHandler");
503
+ expect(pattern).toMatchObject({
504
+ access: { roles: ["TenantAdmin"] },
505
+ description: "new description",
506
+ });
507
+ expect(pattern?.kind).toBe("writeHandler");
508
+ if (pattern?.kind !== "writeHandler") throw new Error("expected a writeHandler pattern");
509
+ expect(pattern.rateLimit).toBeUndefined();
510
+ expect(pattern.schemaSource?.raw).toBe("z.object({})");
511
+ expect(pattern.handlerBody?.raw).toBe("async () => {}");
512
+ expect(syntaxErrors(sf)).toEqual([]);
513
+ });
514
+ });
515
+
516
+ describe("updatePattern — queryHandler / streamHandler", () => {
517
+ test("queryHandler: set description", () => {
518
+ const starter = `
519
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
520
+
521
+ defineFeature("inventory", (r) => {
522
+ r.queryHandler({
523
+ name: "item:get",
524
+ schema: z.object({}),
525
+ handler: async () => {},
526
+ access: { openToAll: { reason: "test" } },
527
+ });
528
+ });
529
+ `;
530
+ const sf = makeSourceFile(starter);
531
+ const change: HandlerHeaderUpdate = {
532
+ op: "update",
533
+ id: { kind: "queryHandler", handlerName: "item:get" },
534
+ set: { description: "Fetches an item" },
535
+ };
536
+ updatePattern(sf, change);
537
+ const reparsed = parseSourceFile(sf);
538
+ expect(reparsed.errors).toEqual([]);
539
+ expect(reparsed.patterns.find((p) => p.kind === "queryHandler")).toMatchObject({
540
+ description: "Fetches an item",
541
+ });
542
+ expect(syntaxErrors(sf)).toEqual([]);
543
+ });
544
+
545
+ test("streamHandler: set rateLimit", () => {
546
+ const starter = `
547
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
548
+
549
+ defineFeature("inventory", (r) => {
550
+ r.streamHandler({
551
+ name: "item:watch",
552
+ schema: z.object({}),
553
+ handler: async function* () {},
554
+ access: { openToAll: { reason: "test" } },
555
+ });
556
+ });
557
+ `;
558
+ const sf = makeSourceFile(starter);
559
+ const change: HandlerHeaderUpdate = {
560
+ op: "update",
561
+ id: { kind: "streamHandler", handlerName: "item:watch" },
562
+ set: { rateLimit: { per: "ip", limit: 3, windowSeconds: 10 } },
563
+ };
564
+ updatePattern(sf, change);
565
+ const reparsed = parseSourceFile(sf);
566
+ expect(reparsed.errors).toEqual([]);
567
+ expect(reparsed.patterns.find((p) => p.kind === "streamHandler")).toMatchObject({
568
+ rateLimit: { per: "ip", limit: 3, windowSeconds: 10 },
569
+ });
570
+ expect(syntaxErrors(sf)).toEqual([]);
571
+ });
572
+ });
573
+
574
+ describe("updatePattern — runtime throws", () => {
575
+ const OBJECT_FORM = `
576
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
577
+
578
+ defineFeature("inventory", (r) => {
579
+ r.writeHandler({
580
+ name: "item:create",
581
+ schema: z.object({}),
582
+ handler: async () => {},
583
+ access: { openToAll: { reason: "test" } },
584
+ });
585
+ });
586
+ `;
587
+
588
+ test("id not found", () => {
589
+ const sf = makeSourceFile(OBJECT_FORM);
590
+ const change: HandlerHeaderUpdate = {
591
+ op: "update",
592
+ id: { kind: "writeHandler", handlerName: "ghost" },
593
+ set: { description: "x" },
594
+ };
595
+ expect(() => updatePattern(sf, change)).toThrow(/no call found/);
596
+ });
597
+
598
+ test("positional call form is rejected", () => {
599
+ const starter = `
600
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
601
+
602
+ defineFeature("inventory", (r) => {
603
+ r.writeHandler("item:create", z.object({}), async () => {}, {
604
+ access: { openToAll: { reason: "test" } },
605
+ });
606
+ });
607
+ `;
608
+ const sf = makeSourceFile(starter);
609
+ const change: HandlerHeaderUpdate = {
610
+ op: "update",
611
+ id: { kind: "writeHandler", handlerName: "item:create" },
612
+ set: { description: "x" },
613
+ };
614
+ expect(() => updatePattern(sf, change)).toThrow(/positional call form.*use replace/);
615
+ });
616
+
617
+ test("opaque handler reference (imported binding) is rejected", () => {
618
+ const starter = `
619
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
620
+ import { importedHandler } from "./handlers";
621
+
622
+ defineFeature("inventory", (r) => {
623
+ r.writeHandler(importedHandler);
624
+ });
625
+ `;
626
+ const sf = makeSourceFile(starter);
627
+ const change: HandlerHeaderUpdate = {
628
+ op: "update",
629
+ id: { kind: "writeHandler", handlerName: "item:create" },
630
+ set: { description: "x" },
631
+ };
632
+ // findCallForId can't match an opaque reference by handlerName either —
633
+ // both failure modes report "no call found" since the id can never be
634
+ // derived from an unresolved identifier.
635
+ expect(() => updatePattern(sf, change)).toThrow(/no call found/);
636
+ });
637
+
638
+ test("a single non-object argument is rejected as not an inline object literal", () => {
639
+ const starter = `
640
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
641
+
642
+ defineFeature("inventory", (r) => {
643
+ r.writeHandler("item:create");
644
+ });
645
+ `;
646
+ const sf = makeSourceFile(starter);
647
+ const change: HandlerHeaderUpdate = {
648
+ op: "update",
649
+ id: { kind: "writeHandler", handlerName: "item:create" },
650
+ set: { description: "x" },
651
+ };
652
+ expect(() => updatePattern(sf, change)).toThrow(/not an inline object literal.*use replace/);
653
+ });
654
+
655
+ test("spread in the handler literal is rejected", () => {
656
+ const starter = `
657
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
658
+
659
+ const shared = { access: { openToAll: { reason: "test" } } };
660
+
661
+ defineFeature("inventory", (r) => {
662
+ r.writeHandler({
663
+ name: "item:create",
664
+ schema: z.object({}),
665
+ handler: async () => {},
666
+ ...shared,
667
+ });
668
+ });
669
+ `;
670
+ const sf = makeSourceFile(starter);
671
+ const change: HandlerHeaderUpdate = {
672
+ op: "update",
673
+ id: { kind: "writeHandler", handlerName: "item:create" },
674
+ set: { description: "x" },
675
+ };
676
+ expect(() => updatePattern(sf, change)).toThrow(/spread/);
677
+ });
678
+
679
+ test("a `constructor` key in set is rejected, not treated as an inherited header field", () => {
680
+ const sf = makeSourceFile(OBJECT_FORM);
681
+ // Bypasses the static `set` type to exercise the runtime boundary check
682
+ // (`Object.hasOwn`, not `in`) directly.
683
+ const change = {
684
+ op: "update",
685
+ id: { kind: "writeHandler", handlerName: "item:create" },
686
+ set: { constructor: "x" },
687
+ } as unknown as HandlerHeaderUpdate;
688
+ expect(() => updatePattern(sf, change)).toThrow(/"constructor" is not a header field/);
689
+ });
690
+ });
691
+
692
+ describe('applyChanges — routes op: "update" to updatePattern', () => {
693
+ test("update participates in a bulk apply", () => {
694
+ const sf = makeSourceFile(`
695
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
696
+
697
+ defineFeature("inventory", (r) => {
698
+ r.writeHandler({
699
+ name: "item:create",
700
+ schema: z.object({}),
701
+ handler: async () => {},
702
+ access: { openToAll: { reason: "test" } },
703
+ });
704
+ });
705
+ `);
706
+ const change: HandlerHeaderUpdate = {
707
+ op: "update",
708
+ id: { kind: "writeHandler", handlerName: "item:create" },
709
+ set: { description: "Creates an item" },
710
+ };
711
+ applyChanges(sf, [change]);
712
+ const reparsed = parseSourceFile(sf);
713
+ expect(reparsed.errors).toEqual([]);
714
+ expect(reparsed.patterns.find((p) => p.kind === "writeHandler")).toMatchObject({
715
+ description: "Creates an item",
716
+ });
717
+ });
718
+ });