@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { registerFooScreens } from "./screens";
|
|
2
|
+
|
|
3
|
+
// biome-ignore lint/suspicious/noExplicitAny: structural parser test fixture, never executed or type-checked at runtime
|
|
4
|
+
declare function defineFeature(name: string, setup: (r: any) => void): void;
|
|
5
|
+
|
|
6
|
+
defineFeature("cross-file-registrar", (r) => {
|
|
7
|
+
r.requires("config");
|
|
8
|
+
registerFooScreens(r);
|
|
9
|
+
});
|
|
@@ -46,26 +46,36 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
46
46
|
{
|
|
47
47
|
path: "packages/bundled-features/src/tenant/feature.ts",
|
|
48
48
|
expectedFeatureName: "tenant",
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// entity + config used to ParseError here: their definitions reference
|
|
50
|
+
// local consts/factory calls, which readDataLiteralNode now resolves
|
|
51
|
+
// to a raw-ref sentinel instead of bailing to undefined (#998).
|
|
52
|
+
recognisedKinds: [
|
|
53
|
+
"requires",
|
|
54
|
+
"systemScope",
|
|
55
|
+
"entity",
|
|
56
|
+
"config",
|
|
57
|
+
"writeHandler",
|
|
58
|
+
"queryHandler",
|
|
59
|
+
],
|
|
60
|
+
errorMethodNames: [],
|
|
51
61
|
},
|
|
52
62
|
{
|
|
53
63
|
path: "packages/bundled-features/src/audit/feature.ts",
|
|
54
64
|
expectedFeatureName: "audit",
|
|
55
|
-
recognisedKinds: [],
|
|
56
|
-
errorMethodNames: [
|
|
65
|
+
recognisedKinds: ["queryHandler"],
|
|
66
|
+
errorMethodNames: [],
|
|
57
67
|
},
|
|
58
68
|
{
|
|
59
69
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
60
70
|
expectedFeatureName: "sessions",
|
|
61
|
-
recognisedKinds: ["entityHook"],
|
|
62
|
-
errorMethodNames: ["unmanagedTable", "
|
|
71
|
+
recognisedKinds: ["entityHook", "writeHandler", "queryHandler"],
|
|
72
|
+
errorMethodNames: ["unmanagedTable", "job"],
|
|
63
73
|
},
|
|
64
74
|
{
|
|
65
75
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|
|
66
76
|
expectedFeatureName: "auth-email-password",
|
|
67
|
-
recognisedKinds: ["requires"],
|
|
68
|
-
errorMethodNames: [
|
|
77
|
+
recognisedKinds: ["requires", "writeHandler"],
|
|
78
|
+
errorMethodNames: [],
|
|
69
79
|
},
|
|
70
80
|
];
|
|
71
81
|
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// Designer/AI know the call exists.
|
|
12
12
|
|
|
13
13
|
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { resolve } from "node:path";
|
|
14
15
|
import { Project } from "ts-morph";
|
|
15
|
-
import {
|
|
16
|
+
import { isRawRefSentinel, readDataLiteralNode } from "../extractors/shared";
|
|
17
|
+
import { parseFeatureFile, parseSourceFile } from "../parse";
|
|
16
18
|
|
|
17
19
|
function createProject() {
|
|
18
20
|
return new Project({
|
|
@@ -122,6 +124,102 @@ defineFeature("isolated", (r) => {
|
|
|
122
124
|
expect(result.featureName).toBe("nameOnly");
|
|
123
125
|
expect(result.patterns).toEqual([]);
|
|
124
126
|
});
|
|
127
|
+
|
|
128
|
+
test("recurses into a locally declared registrar-wrapper function", () => {
|
|
129
|
+
const result = parseInline(`
|
|
130
|
+
function registerTaskScreens(registrar) {
|
|
131
|
+
registrar.entity("task", { fields: {} });
|
|
132
|
+
registrar.requires("auth");
|
|
133
|
+
}
|
|
134
|
+
defineFeature("wrapped", (r) => {
|
|
135
|
+
r.systemScope();
|
|
136
|
+
registerTaskScreens(r);
|
|
137
|
+
});
|
|
138
|
+
`);
|
|
139
|
+
|
|
140
|
+
expect(result.errors).toEqual([]);
|
|
141
|
+
expect(result.patterns.map((p) => p.kind)).toEqual(["systemScope", "entity", "requires"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("recurses into a registrar-wrapper declared as a const arrow function", () => {
|
|
145
|
+
const result = parseInline(`
|
|
146
|
+
const registerTaskScreens = (registrar) => {
|
|
147
|
+
registrar.entity("task", { fields: {} });
|
|
148
|
+
};
|
|
149
|
+
defineFeature("wrapped", (r) => {
|
|
150
|
+
registerTaskScreens(r);
|
|
151
|
+
});
|
|
152
|
+
`);
|
|
153
|
+
|
|
154
|
+
expect(result.errors).toEqual([]);
|
|
155
|
+
expect(result.patterns).toMatchObject([{ kind: "entity", entityName: "task" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("ignores a bare call that does not receive the registrar as an argument", () => {
|
|
159
|
+
const result = parseInline(`
|
|
160
|
+
function unrelated() {
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
defineFeature("f", (r) => {
|
|
164
|
+
unrelated();
|
|
165
|
+
r.systemScope();
|
|
166
|
+
});
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
expect(result.errors).toEqual([]);
|
|
170
|
+
expect(result.patterns).toMatchObject([{ kind: "systemScope" }]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("does not infinite-loop on a registrar-wrapper cycle", () => {
|
|
174
|
+
const result = parseInline(`
|
|
175
|
+
function a(registrar) {
|
|
176
|
+
registrar.systemScope();
|
|
177
|
+
b(registrar);
|
|
178
|
+
}
|
|
179
|
+
function b(registrar) {
|
|
180
|
+
a(registrar);
|
|
181
|
+
}
|
|
182
|
+
defineFeature("f", (r) => {
|
|
183
|
+
a(r);
|
|
184
|
+
});
|
|
185
|
+
`);
|
|
186
|
+
|
|
187
|
+
expect(result.errors).toEqual([]);
|
|
188
|
+
expect(result.patterns).toMatchObject([{ kind: "systemScope" }]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("readDataLiteralNode — raw-ref sentinel", () => {
|
|
193
|
+
function readExpression(source: string) {
|
|
194
|
+
const project = createProject();
|
|
195
|
+
const sourceFile = project.createSourceFile("inline.ts", `const __probe__ = (${source});`);
|
|
196
|
+
const decl = sourceFile.getVariableDeclarationOrThrow("__probe__");
|
|
197
|
+
return readDataLiteralNode(decl.getInitializerOrThrow());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
test("Identifier reference resolves to a raw-ref sentinel with the exact source text", () => {
|
|
201
|
+
const value = readExpression("eventEntity");
|
|
202
|
+
expect(isRawRefSentinel(value)).toBe(true);
|
|
203
|
+
expect(value).toEqual({ __raw: "eventEntity" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("zero-arg call expression resolves to a raw-ref sentinel with the exact source text", () => {
|
|
207
|
+
const value = readExpression("createInviteBrandingQuery()");
|
|
208
|
+
expect(isRawRefSentinel(value)).toBe(true);
|
|
209
|
+
expect(value).toEqual({ __raw: "createInviteBrandingQuery()" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("member access resolves to a raw-ref sentinel", () => {
|
|
213
|
+
const value = readExpression("config.timeout");
|
|
214
|
+
expect(isRawRefSentinel(value)).toBe(true);
|
|
215
|
+
expect(value).toEqual({ __raw: "config.timeout" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a single unresolvable property no longer bubbles the whole object to undefined", () => {
|
|
219
|
+
const value = readExpression("{ timeout: 30, query: createQuery() }");
|
|
220
|
+
expect(isRawRefSentinel(value)).toBe(false);
|
|
221
|
+
expect(value).toEqual({ timeout: 30, query: { __raw: "createQuery()" } });
|
|
222
|
+
});
|
|
125
223
|
});
|
|
126
224
|
|
|
127
225
|
// =============================================================================
|
|
@@ -143,12 +241,26 @@ defineFeature("f", (r) => {
|
|
|
143
241
|
expect(result.errors).toEqual([]);
|
|
144
242
|
});
|
|
145
243
|
|
|
146
|
-
test("
|
|
244
|
+
test("resolves a local const identifier arg via the raw-ref sentinel (#1009)", () => {
|
|
147
245
|
const result = parseInline(`
|
|
148
246
|
const dep = "auth";
|
|
149
247
|
defineFeature("f", (r) => {
|
|
150
248
|
r.requires(dep);
|
|
151
249
|
});
|
|
250
|
+
`);
|
|
251
|
+
|
|
252
|
+
expect(result.patterns[0]).toMatchObject({
|
|
253
|
+
kind: "requires",
|
|
254
|
+
featureNames: [{ __raw: "dep" }],
|
|
255
|
+
});
|
|
256
|
+
expect(result.errors).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("emits a ParseError when an argument cannot be resolved at all", () => {
|
|
260
|
+
const result = parseInline(`
|
|
261
|
+
defineFeature("f", (r) => {
|
|
262
|
+
r.requires(5);
|
|
263
|
+
});
|
|
152
264
|
`);
|
|
153
265
|
|
|
154
266
|
expect(result.patterns).toEqual([]);
|
|
@@ -170,6 +282,21 @@ defineFeature("f", (r) => {
|
|
|
170
282
|
featureNames: ["billing"],
|
|
171
283
|
});
|
|
172
284
|
});
|
|
285
|
+
|
|
286
|
+
test("keeps a computed feature-name arg as a raw-ref sentinel instead of failing (#1009)", () => {
|
|
287
|
+
const result = parseInline(`
|
|
288
|
+
const someFeature = { name: "billing" };
|
|
289
|
+
defineFeature("f", (r) => {
|
|
290
|
+
r.optionalRequires(someFeature.name);
|
|
291
|
+
});
|
|
292
|
+
`);
|
|
293
|
+
|
|
294
|
+
expect(result.patterns[0]).toMatchObject({
|
|
295
|
+
kind: "optionalRequires",
|
|
296
|
+
featureNames: [{ __raw: "someFeature.name" }],
|
|
297
|
+
});
|
|
298
|
+
expect(result.errors).toEqual([]);
|
|
299
|
+
});
|
|
173
300
|
});
|
|
174
301
|
|
|
175
302
|
describe("extractReadsConfig", () => {
|
|
@@ -185,6 +312,21 @@ defineFeature("f", (r) => {
|
|
|
185
312
|
qualifiedKeys: ["auth:config:jwt-ttl", "tenant:config:locale"],
|
|
186
313
|
});
|
|
187
314
|
});
|
|
315
|
+
|
|
316
|
+
test("keeps a computed key arg as a raw-ref sentinel instead of failing (#1009)", () => {
|
|
317
|
+
const result = parseInline(`
|
|
318
|
+
const cfg = { key: "auth:config:jwt-ttl" };
|
|
319
|
+
defineFeature("f", (r) => {
|
|
320
|
+
r.readsConfig(cfg.key, "tenant:config:locale");
|
|
321
|
+
});
|
|
322
|
+
`);
|
|
323
|
+
|
|
324
|
+
expect(result.patterns[0]).toMatchObject({
|
|
325
|
+
kind: "readsConfig",
|
|
326
|
+
qualifiedKeys: [{ __raw: "cfg.key" }, "tenant:config:locale"],
|
|
327
|
+
});
|
|
328
|
+
expect(result.errors).toEqual([]);
|
|
329
|
+
});
|
|
188
330
|
});
|
|
189
331
|
|
|
190
332
|
describe("extractSystemScope", () => {
|
|
@@ -269,6 +411,40 @@ defineFeature("f", (r) => {
|
|
|
269
411
|
expect(result.errors).toEqual([]);
|
|
270
412
|
});
|
|
271
413
|
|
|
414
|
+
test("keeps an unresolved const reference as a raw-ref sentinel instead of erroring", () => {
|
|
415
|
+
const result = parseInline(`
|
|
416
|
+
const eventEntity = { fields: { name: { type: "text" } } };
|
|
417
|
+
defineFeature("f", (r) => {
|
|
418
|
+
r.entity("event", eventEntity);
|
|
419
|
+
});
|
|
420
|
+
`);
|
|
421
|
+
|
|
422
|
+
expect(result.errors).toEqual([]);
|
|
423
|
+
expect(result.patterns[0]).toMatchObject({
|
|
424
|
+
kind: "entity",
|
|
425
|
+
entityName: "event",
|
|
426
|
+
definition: { __raw: "eventEntity" },
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("keeps a factory call reference inside a nested property as a raw-ref sentinel", () => {
|
|
431
|
+
const result = parseInline(`
|
|
432
|
+
function buildFields() {
|
|
433
|
+
return { name: { type: "text" } };
|
|
434
|
+
}
|
|
435
|
+
defineFeature("f", (r) => {
|
|
436
|
+
r.entity("task", { fields: buildFields() });
|
|
437
|
+
});
|
|
438
|
+
`);
|
|
439
|
+
|
|
440
|
+
expect(result.errors).toEqual([]);
|
|
441
|
+
expect(result.patterns[0]).toMatchObject({
|
|
442
|
+
kind: "entity",
|
|
443
|
+
entityName: "task",
|
|
444
|
+
definition: { fields: { __raw: "buildFields()" } },
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
272
448
|
test("walks through `as const` and `satisfies` wrappers", () => {
|
|
273
449
|
const result = parseInline(`
|
|
274
450
|
defineFeature("f", (r) => {
|
|
@@ -681,6 +857,40 @@ defineFeature("f", (r) => {
|
|
|
681
857
|
access: { openToAll: true },
|
|
682
858
|
});
|
|
683
859
|
});
|
|
860
|
+
|
|
861
|
+
test("resolves a same-file identifier arg to its object-literal form (#1007)", () => {
|
|
862
|
+
const result = parseInline(`
|
|
863
|
+
const h = {
|
|
864
|
+
name: "task:archive",
|
|
865
|
+
schema: z.object({ id: z.string() }),
|
|
866
|
+
handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
|
|
867
|
+
};
|
|
868
|
+
defineFeature("f", (r) => {
|
|
869
|
+
r.writeHandler(h);
|
|
870
|
+
});
|
|
871
|
+
`);
|
|
872
|
+
|
|
873
|
+
expect(result.patterns[0]).toMatchObject({
|
|
874
|
+
kind: "writeHandler",
|
|
875
|
+
handlerName: "task:archive",
|
|
876
|
+
});
|
|
877
|
+
expect(result.errors).toEqual([]);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("keeps an unresolvable ref (import, factory call) as an opaque pattern instead of failing (#1007)", () => {
|
|
881
|
+
const result = parseInline(`
|
|
882
|
+
defineFeature("f", (r) => {
|
|
883
|
+
r.writeHandler(eventCreateHandler);
|
|
884
|
+
});
|
|
885
|
+
`);
|
|
886
|
+
|
|
887
|
+
expect(result.patterns[0]).toMatchObject({
|
|
888
|
+
kind: "writeHandler",
|
|
889
|
+
handlerName: undefined,
|
|
890
|
+
source: { raw: "r.writeHandler(eventCreateHandler)" },
|
|
891
|
+
});
|
|
892
|
+
expect(result.errors).toEqual([]);
|
|
893
|
+
});
|
|
684
894
|
});
|
|
685
895
|
|
|
686
896
|
describe("extractQueryHandler", () => {
|
|
@@ -886,3 +1096,82 @@ defineFeature("f", (r) => {
|
|
|
886
1096
|
});
|
|
887
1097
|
});
|
|
888
1098
|
});
|
|
1099
|
+
|
|
1100
|
+
// =============================================================================
|
|
1101
|
+
// Regression — show-pony-shaped feature (#998)
|
|
1102
|
+
// =============================================================================
|
|
1103
|
+
// Mirrors the real-world idioms from show-pony/src/features/show-pony/feature.ts
|
|
1104
|
+
// that motivated #998 — inline here (not the live external repo path) so this
|
|
1105
|
+
// test doesn't depend on the show-pony checkout being present in this repo's CI.
|
|
1106
|
+
|
|
1107
|
+
describe("show-pony-shaped feature (regression, #998)", () => {
|
|
1108
|
+
const result = parseInline(`
|
|
1109
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
1110
|
+
import { eventEntity } from "./schema";
|
|
1111
|
+
import { eventCreateHandler } from "./handlers/event-create.write";
|
|
1112
|
+
import { createInviteBrandingQuery } from "./handlers/invite-branding.query";
|
|
1113
|
+
import { registerShowPonyScreens } from "./register/screens";
|
|
1114
|
+
|
|
1115
|
+
const someFeature = { name: "mail-foundation" };
|
|
1116
|
+
|
|
1117
|
+
defineFeature("showpony", (r) => {
|
|
1118
|
+
r.requires(someFeature.name, "config");
|
|
1119
|
+
r.entity("event", eventEntity);
|
|
1120
|
+
r.writeHandler(eventCreateHandler);
|
|
1121
|
+
r.queryHandler(createInviteBrandingQuery());
|
|
1122
|
+
registerShowPonyScreens(r);
|
|
1123
|
+
});
|
|
1124
|
+
`);
|
|
1125
|
+
|
|
1126
|
+
test("requires()'s computed name and entity's imported const resolve via the raw-ref sentinel (#998/#1009 fix)", () => {
|
|
1127
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1128
|
+
kind: "requires",
|
|
1129
|
+
featureNames: [{ __raw: "someFeature.name" }, "config"],
|
|
1130
|
+
});
|
|
1131
|
+
expect(result.patterns[1]).toMatchObject({
|
|
1132
|
+
kind: "entity",
|
|
1133
|
+
entityName: "event",
|
|
1134
|
+
definition: { __raw: "eventEntity" },
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test("writeHandler/queryHandler(handlerRef) resolve as opaque patterns (#998/#1007 fix)", () => {
|
|
1139
|
+
expect(result.patterns).toMatchObject([
|
|
1140
|
+
{ kind: "requires" },
|
|
1141
|
+
{ kind: "entity" },
|
|
1142
|
+
{ kind: "writeHandler", handlerName: undefined },
|
|
1143
|
+
{ kind: "queryHandler", handlerName: undefined },
|
|
1144
|
+
]);
|
|
1145
|
+
expect(result.errors).toEqual([]);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("an imported registrar-wrapper stays invisible when its file can't be resolved (in-memory project, #1008)", () => {
|
|
1149
|
+
// parseInline uses an in-memory ts-morph Project — "./register/screens"
|
|
1150
|
+
// isn't a real file, so getModuleSpecifierSourceFile() returns
|
|
1151
|
+
// undefined and the wrapper is skipped like any other unresolvable
|
|
1152
|
+
// call. Real cross-file resolution against on-disk files is covered by
|
|
1153
|
+
// the parseFeatureFile fixture tests below.
|
|
1154
|
+
expect(result.patterns.some((p) => p.kind === "screen" || p.kind === "nav")).toBe(false);
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
describe("cross-file registrar-wrapper resolution against a real filesystem Project (#1008)", () => {
|
|
1159
|
+
const fixture = resolve(__dirname, "fixtures/cross-file-registrar/feature.ts");
|
|
1160
|
+
const result = parseFeatureFile(fixture);
|
|
1161
|
+
|
|
1162
|
+
test("resolves the imported wrapper and recognises the nav pattern it registers", () => {
|
|
1163
|
+
expect(result.errors).toEqual([]);
|
|
1164
|
+
expect(result.patterns).toMatchObject([
|
|
1165
|
+
{ kind: "requires", featureNames: ["config"] },
|
|
1166
|
+
{ kind: "nav", definition: { id: "foo", label: "Foo" } },
|
|
1167
|
+
]);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
test("the resolved pattern's source points at the imported file, not the entry file", () => {
|
|
1171
|
+
const navPattern = result.patterns.find((p) => p.kind === "nav");
|
|
1172
|
+
expect(navPattern?.source.file).toBe(
|
|
1173
|
+
resolve(__dirname, "fixtures/cross-file-registrar/screens.ts"),
|
|
1174
|
+
);
|
|
1175
|
+
expect(navPattern?.source.file).not.toBe(fixture);
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
@@ -120,7 +120,7 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
|
|
|
120
120
|
expect(pattern?.kind).toBe("writeHandler");
|
|
121
121
|
if (pattern?.kind === "writeHandler") {
|
|
122
122
|
expect(pattern.handlerName).toBe("task:create");
|
|
123
|
-
expect(pattern.handlerBody
|
|
123
|
+
expect(pattern.handlerBody?.raw).toContain("isSuccess: true");
|
|
124
124
|
expect(pattern.access).toMatchObject({ roles: ["user"] });
|
|
125
125
|
}
|
|
126
126
|
});
|
|
@@ -237,6 +237,79 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
|
|
|
237
237
|
// guarantee than parse → render → parse, because it pins that the renderer
|
|
238
238
|
// never depends on the input file's whitespace — its output is deterministic
|
|
239
239
|
// from the parsed FeaturePattern shape alone.
|
|
240
|
+
const RAW_REF_FEATURE = `
|
|
241
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
242
|
+
|
|
243
|
+
const eventEntity = {
|
|
244
|
+
fields: { name: { type: "text", required: true } },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function buildFields() {
|
|
248
|
+
return { title: { type: "text" } };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const someFeature = { name: "mail-foundation" };
|
|
252
|
+
|
|
253
|
+
const eventListScreen = {
|
|
254
|
+
id: "event-list",
|
|
255
|
+
type: "entityList",
|
|
256
|
+
entity: "event",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function makeHandler() {
|
|
260
|
+
return { name: "task:create", schema: null, handler: () => {} };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
defineFeature("refs", (r) => {
|
|
264
|
+
r.requires(someFeature.name, "config");
|
|
265
|
+
r.entity("event", eventEntity);
|
|
266
|
+
r.entity("task", { fields: buildFields() });
|
|
267
|
+
r.writeHandler(makeHandler());
|
|
268
|
+
r.screen(eventListScreen);
|
|
269
|
+
});
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
describe("render → parse roundtrip — unresolved references (raw-ref sentinel)", () => {
|
|
273
|
+
const initial = parse(RAW_REF_FEATURE);
|
|
274
|
+
|
|
275
|
+
test("parses requires + both entities + the opaque handler without error, keeping references unresolved", () => {
|
|
276
|
+
expect(initial.patterns).toMatchObject([
|
|
277
|
+
{ kind: "requires", featureNames: [{ __raw: "someFeature.name" }, "config"] },
|
|
278
|
+
{ kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
|
|
279
|
+
{ kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
|
|
280
|
+
{ kind: "writeHandler", handlerName: undefined },
|
|
281
|
+
{ kind: "screen", definition: { __raw: "eventListScreen" } },
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("rendered output re-emits references verbatim, never inlines them", () => {
|
|
286
|
+
const rendered = renderFeatureFile({
|
|
287
|
+
featureName: initial.featureName ?? "",
|
|
288
|
+
patterns: initial.patterns,
|
|
289
|
+
});
|
|
290
|
+
expect(rendered).toContain("someFeature.name");
|
|
291
|
+
expect(rendered).toContain("eventEntity");
|
|
292
|
+
expect(rendered).toContain("buildFields()");
|
|
293
|
+
expect(rendered).toContain("r.writeHandler(makeHandler())");
|
|
294
|
+
expect(rendered).toContain("r.screen(eventListScreen);");
|
|
295
|
+
// Would only appear if buildFields()'s return value got inlined.
|
|
296
|
+
expect(rendered).not.toContain("title:");
|
|
297
|
+
expect(rendered).not.toContain("mail-foundation");
|
|
298
|
+
expect(rendered).not.toContain("task:create");
|
|
299
|
+
// Would only appear if the screen ref got resolved/inlined.
|
|
300
|
+
expect(rendered).not.toContain("entityList");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("pattern shape survives a full render → reparse cycle", () => {
|
|
304
|
+
const rendered = renderFeatureFile({
|
|
305
|
+
featureName: initial.featureName ?? "",
|
|
306
|
+
patterns: initial.patterns,
|
|
307
|
+
});
|
|
308
|
+
const reparsed = parse(rendered);
|
|
309
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
240
313
|
describe("render idempotence", () => {
|
|
241
314
|
test("rendered file → parse → render === rendered file", () => {
|
|
242
315
|
const initial = parse(STATIC_FEATURE);
|
|
@@ -33,7 +33,7 @@ export function extractRequires(
|
|
|
33
33
|
return ok({
|
|
34
34
|
kind: "requires",
|
|
35
35
|
source: sourceLocationFromNode(call, sourceFile),
|
|
36
|
-
featureNames: names,
|
|
36
|
+
featureNames: names as readonly string[],
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ export function extractOptionalRequires(
|
|
|
52
52
|
return ok({
|
|
53
53
|
kind: "optionalRequires",
|
|
54
54
|
source: sourceLocationFromNode(call, sourceFile),
|
|
55
|
-
featureNames: names,
|
|
55
|
+
featureNames: names as readonly string[],
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -71,7 +71,7 @@ export function extractReadsConfig(
|
|
|
71
71
|
return ok({
|
|
72
72
|
kind: "readsConfig",
|
|
73
73
|
source: sourceLocationFromNode(call, sourceFile),
|
|
74
|
-
qualifiedKeys: keys,
|
|
74
|
+
qualifiedKeys: keys as readonly string[],
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
fail,
|
|
32
32
|
findFunctionLiteral,
|
|
33
33
|
isPlainObject,
|
|
34
|
+
isRawRefSentinel,
|
|
34
35
|
ok,
|
|
35
36
|
readBooleanProperty,
|
|
36
37
|
readDataLiteralNode,
|
|
@@ -408,14 +409,29 @@ export function extractAuthClaims(
|
|
|
408
409
|
|
|
409
410
|
export type ParsedHandlerCall = {
|
|
410
411
|
readonly source: SourceLocation;
|
|
411
|
-
readonly handlerName
|
|
412
|
-
readonly schemaSource
|
|
413
|
-
readonly handlerBody
|
|
412
|
+
readonly handlerName?: string;
|
|
413
|
+
readonly schemaSource?: SourceLocation;
|
|
414
|
+
readonly handlerBody?: SourceLocation;
|
|
414
415
|
readonly access?: AccessRule;
|
|
415
416
|
readonly rateLimit?: RateLimitOption;
|
|
416
417
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
417
418
|
};
|
|
418
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Resolves an argument standing in for a handler-call's object-form body:
|
|
422
|
+
* either it's already an object literal, or a bare identifier declared
|
|
423
|
+
* locally (same file only) with one as its initializer. Anything else
|
|
424
|
+
* (imported binding, factory call, ...) is not resolvable here.
|
|
425
|
+
*/
|
|
426
|
+
function resolveObjectLiteralArg(node: Node) {
|
|
427
|
+
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
428
|
+
if (direct) return direct;
|
|
429
|
+
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
430
|
+
if (!identifier) return undefined;
|
|
431
|
+
const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
432
|
+
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
433
|
+
}
|
|
434
|
+
|
|
419
435
|
export function parseHandlerCall(
|
|
420
436
|
call: CallExpression,
|
|
421
437
|
sourceFile: SourceFile,
|
|
@@ -431,8 +447,8 @@ export function parseHandlerCall(
|
|
|
431
447
|
);
|
|
432
448
|
}
|
|
433
449
|
|
|
434
|
-
const obj =
|
|
435
|
-
if (obj
|
|
450
|
+
const obj = args.length === 1 ? resolveObjectLiteralArg(first) : undefined;
|
|
451
|
+
if (obj) {
|
|
436
452
|
const nameLiteral = obj
|
|
437
453
|
.getProperty("name")
|
|
438
454
|
?.asKind(SyntaxKind.PropertyAssignment)
|
|
@@ -499,6 +515,16 @@ export function parseHandlerCall(
|
|
|
499
515
|
});
|
|
500
516
|
}
|
|
501
517
|
|
|
518
|
+
// A single reference standing in for the whole handler argument set
|
|
519
|
+
// (`r.writeHandler(eventCreateHandler)`, `r.queryHandler(someQuery())`)
|
|
520
|
+
// that resolveObjectLiteralArg above couldn't turn into an object literal
|
|
521
|
+
// (imported binding, factory call, ...). Keep it recognised as this kind
|
|
522
|
+
// instead of ParseErroring — see #1007. Opaque: renderWriteHandler/
|
|
523
|
+
// renderQueryHandler re-emit `source.raw` verbatim when handlerName is
|
|
524
|
+
// undefined.
|
|
525
|
+
if (args.length === 1 && isRawRefSentinel(readDataLiteralNode(first))) {
|
|
526
|
+
return ok({ source: sourceLocationFromNode(call, sourceFile) });
|
|
527
|
+
}
|
|
502
528
|
const nameLiteral = first.asKind(SyntaxKind.StringLiteral);
|
|
503
529
|
if (!nameLiteral) {
|
|
504
530
|
return fail(
|
|
@@ -1341,11 +1367,20 @@ export function extractScreen(
|
|
|
1341
1367
|
}
|
|
1342
1368
|
const obj = arg.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
1343
1369
|
if (!obj) {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1370
|
+
const raw = readDataLiteralNode(arg);
|
|
1371
|
+
if (!isRawRefSentinel(raw)) {
|
|
1372
|
+
return fail(
|
|
1373
|
+
"screen",
|
|
1374
|
+
sourceLocationFromNode(call, sourceFile),
|
|
1375
|
+
"argument must be an inline object literal",
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
return ok({
|
|
1379
|
+
kind: "screen",
|
|
1380
|
+
source: sourceLocationFromNode(call, sourceFile),
|
|
1381
|
+
definition: raw as unknown as ScreenDefinition,
|
|
1382
|
+
opaqueProps: {} as OpaquePropMap,
|
|
1383
|
+
});
|
|
1349
1384
|
}
|
|
1350
1385
|
const opaqueProps: Record<string, SourceLocation> = {};
|
|
1351
1386
|
collectScreenOpaqueProps(obj, "", sourceFile, opaqueProps);
|