@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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- 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
|
+
});
|