@cosmicdrift/kumiko-framework 0.154.1 → 0.154.2
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 +2 -2
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +9 -9
- package/src/engine/__tests__/engine.test.ts +16 -0
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/define-feature.ts +22 -4
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +8 -7
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +22 -8
- package/src/engine/feature-ast/__tests__/patcher.test.ts +8 -8
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +210 -4
- package/src/engine/feature-ast/extractors/index.ts +0 -2
- package/src/engine/feature-ast/extractors/round4.ts +21 -151
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +42 -11
- package/src/engine/feature-ast/patcher.ts +4 -20
- package/src/engine/feature-ast/patterns.ts +10 -22
- package/src/engine/feature-ast/render.ts +7 -14
- package/src/engine/feature-config-events-jobs.ts +74 -24
- package/src/engine/feature-entity-handlers.ts +24 -6
- package/src/engine/feature-ui-extensions.ts +116 -45
- package/src/engine/object-form.ts +12 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -9
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +1 -45
- package/src/engine/pattern-library/shared-fields.ts +1 -6
- package/src/engine/registry-ingest.ts +7 -2
- package/src/engine/types/feature.ts +58 -28
- package/src/engine/types/nav.ts +4 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +66 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +200 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/meilisearch-adapter.ts +13 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.154.
|
|
3
|
+
"version": "0.154.2",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.154.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.154.2",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -182,7 +182,7 @@ const userFeature = defineFeature("users", (r) => {
|
|
|
182
182
|
{ access: { openToAll: true } },
|
|
183
183
|
);
|
|
184
184
|
|
|
185
|
-
r.
|
|
185
|
+
r.hook("postSave", { allOf: user }, async (result) => {
|
|
186
186
|
featurePostSaveLog.push(result);
|
|
187
187
|
});
|
|
188
188
|
|
|
@@ -81,9 +81,9 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
81
81
|
);
|
|
82
82
|
|
|
83
83
|
// Entity hook: inTransaction — records in memory
|
|
84
|
-
r.
|
|
84
|
+
r.hook(
|
|
85
85
|
"postSave",
|
|
86
|
-
item,
|
|
86
|
+
{ allOf: item },
|
|
87
87
|
async (result: SaveContext) => {
|
|
88
88
|
inTxHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
|
|
89
89
|
},
|
|
@@ -92,9 +92,9 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
92
92
|
|
|
93
93
|
// Entity hook: inTransaction — writes to DB via ctx.db (the tx-scoped TenantDb).
|
|
94
94
|
// Proves that hook DB writes roll back with the main transaction on failure.
|
|
95
|
-
r.
|
|
95
|
+
r.hook(
|
|
96
96
|
"postSave",
|
|
97
|
-
item,
|
|
97
|
+
{ allOf: item },
|
|
98
98
|
async (result, ctx) => {
|
|
99
99
|
if (!ctx.db) return;
|
|
100
100
|
await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
|
|
@@ -103,32 +103,32 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
103
103
|
);
|
|
104
104
|
|
|
105
105
|
// Entity hook: afterCommit — records in memory (default phase)
|
|
106
|
-
r.
|
|
106
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
107
107
|
afterCommitHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
// Entity hook: afterCommit — may throw, used to verify error isolation
|
|
111
|
-
r.
|
|
111
|
+
r.hook("postSave", { allOf: item }, async () => {
|
|
112
112
|
if (afterCommitShouldThrow) throw new Error("afterCommit_boom");
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
// Entity hook: afterCommit — runs AFTER the throwing one. Used to prove the
|
|
116
116
|
// next hooks still fire despite the earlier failure.
|
|
117
|
-
r.
|
|
117
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
118
118
|
afterCommitThirdHookRan.push((result.data["name"] as string) ?? "");
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
// Two hooks used by the parallelism test. Each records its start+end
|
|
122
122
|
// timestamps so the assertion can compare intervals rather than elapsed
|
|
123
123
|
// wall-clock time (which is timing-flaky on loaded CI boxes).
|
|
124
|
-
r.
|
|
124
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
125
125
|
const name = result.data["name"] as string;
|
|
126
126
|
if (!name?.startsWith("slowness-")) return;
|
|
127
127
|
parallelismWindows.push({ hook: "A", start: Date.now() });
|
|
128
128
|
await new Promise((r) => setTimeout(r, 80));
|
|
129
129
|
parallelismWindows.push({ hook: "A", end: Date.now() });
|
|
130
130
|
});
|
|
131
|
-
r.
|
|
131
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
132
132
|
const name = result.data["name"] as string;
|
|
133
133
|
if (!name?.startsWith("slowness-")) return;
|
|
134
134
|
parallelismWindows.push({ hook: "B", start: Date.now() });
|
|
@@ -439,6 +439,22 @@ describe("createRegistry", () => {
|
|
|
439
439
|
expect(all["profile:nav.title"]).toEqual({ de: "Profil", en: "Profile" });
|
|
440
440
|
});
|
|
441
441
|
|
|
442
|
+
// #1105: a feature that already qualifies its own translation keys (nav
|
|
443
|
+
// labels referencing "featureName:..." verbatim, see cap-counter) must not
|
|
444
|
+
// be double-prefixed — else server-side t() can never resolve them.
|
|
445
|
+
test("does not double-prefix translation keys that already carry the feature's own namespace", () => {
|
|
446
|
+
const f = defineFeature("cap-counter", (r) => {
|
|
447
|
+
r.translations({
|
|
448
|
+
keys: { "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" } },
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const registry = createRegistry([f]);
|
|
453
|
+
const all = registry.getAllTranslations();
|
|
454
|
+
expect(all["cap-counter:nav.cap-list"]).toEqual({ de: "Limits", en: "Caps" });
|
|
455
|
+
expect(all["cap-counter:cap-counter:nav.cap-list"]).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
|
|
442
458
|
test("throws when write handler is not entity-mapped in feature with field-access", () => {
|
|
443
459
|
const feature = defineFeature("hr", (r) => {
|
|
444
460
|
r.entity(
|
|
@@ -59,11 +59,11 @@ describe("HookPhases defaults", () => {
|
|
|
59
59
|
expect(entry?.[0]?.phase).toBe(HookPhases.inTransaction);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
test("
|
|
62
|
+
test("hook({allOf}) postSave defaults to afterCommit, preDelete is forced inTransaction", () => {
|
|
63
63
|
const feature = defineFeature("test", (r) => {
|
|
64
64
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
65
|
-
r.
|
|
66
|
-
r.
|
|
65
|
+
r.hook("postSave", { allOf: thing }, noopSave);
|
|
66
|
+
r.hook("preDelete", { allOf: thing }, async () => undefined);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
expect(feature.entityHooks?.postSave?.["thing"]?.[0]?.phase).toBe(HookPhases.afterCommit);
|
|
@@ -120,8 +120,8 @@ describe("Registry phase filtering", () => {
|
|
|
120
120
|
|
|
121
121
|
const feature = defineFeature("test", (r) => {
|
|
122
122
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
123
|
-
r.
|
|
124
|
-
r.
|
|
123
|
+
r.hook("postSave", { allOf: thing }, inTxFn, { phase: HookPhases.inTransaction });
|
|
124
|
+
r.hook("postSave", { allOf: thing }, afterFn);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
const registry = createRegistry([feature]);
|
|
@@ -10,7 +10,7 @@ const stubContext = {} as unknown as AppContext;
|
|
|
10
10
|
// postQuery-Hook (F1) — feuert nach Query-Handler-Execute, vor Field-Access-
|
|
11
11
|
// Read-Filter. Zwei Registrierungs-Pfade:
|
|
12
12
|
// - r.hook("postQuery", "ns:query:list", fn) — handler-keyed
|
|
13
|
-
// - r.
|
|
13
|
+
// - r.hook("postQuery", { allOf: "thing" }, fn) — entity-keyed (alle Queries)
|
|
14
14
|
//
|
|
15
15
|
// Diese Tests pinnen die Invarianten:
|
|
16
16
|
// 1. Beide Registrierungs-Pfade landen in unabhängigen Maps
|
|
@@ -35,10 +35,10 @@ describe("postQuery hook registration", () => {
|
|
|
35
35
|
expect(entry?.[0]?.featureName).toBe("test");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("r.
|
|
38
|
+
test("r.hook('postQuery', { allOf: entity }, fn) lands in entity-keyed entityHooks map", () => {
|
|
39
39
|
const feature = defineFeature("test", (r) => {
|
|
40
40
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
41
|
-
r.
|
|
41
|
+
r.hook("postQuery", { allOf: thing }, noop);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
const entry = feature.entityHooks?.postQuery?.["thing"];
|
|
@@ -52,8 +52,8 @@ describe("postQuery hook registration", () => {
|
|
|
52
52
|
|
|
53
53
|
const feature = defineFeature("test", (r) => {
|
|
54
54
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
55
|
-
r.
|
|
56
|
-
r.
|
|
55
|
+
r.hook("postQuery", { allOf: thing }, hookA);
|
|
56
|
+
r.hook("postQuery", { allOf: thing }, hookB);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
expect(feature.entityHooks?.postQuery?.["thing"]).toHaveLength(2);
|
|
@@ -81,7 +81,7 @@ describe("Registry getters", () => {
|
|
|
81
81
|
|
|
82
82
|
const feature = defineFeature("test", (r) => {
|
|
83
83
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
84
|
-
r.
|
|
84
|
+
r.hook("postQuery", { allOf: thing }, fn);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
const registry = createRegistry([feature]);
|
|
@@ -109,7 +109,7 @@ describe("Hook function semantics", () => {
|
|
|
109
109
|
|
|
110
110
|
const feature = defineFeature("test", (r) => {
|
|
111
111
|
const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
|
|
112
|
-
r.
|
|
112
|
+
r.hook("postQuery", { allOf: thing }, enrich);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
const registry = createRegistry([feature]);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../index";
|
|
3
|
+
|
|
4
|
+
// Object-Form is the shape the feature-ast renderer emits for Designer/
|
|
5
|
+
// AI-generated code (`r.entity({ name: "item", ... })` instead of
|
|
6
|
+
// `r.entity("item", { ... })`) — see kumiko-framework#1113. The compile
|
|
7
|
+
// checks in feature-ast/__tests__/render-roundtrip.test.ts prove the TYPES
|
|
8
|
+
// line up; they never execute the runtime dispatch that unpacks Object-Form
|
|
9
|
+
// back into the same state mutations as the positional form. These tests
|
|
10
|
+
// cover that: one per distinct dispatch shape, asserting object-form and
|
|
11
|
+
// positional-form produce identical FeatureDefinition state.
|
|
12
|
+
|
|
13
|
+
describe("registrar Object-Form — runtime parity with positional form", () => {
|
|
14
|
+
test("requires: array-unwrap ({ features }) matches variadic-string form", () => {
|
|
15
|
+
const positional = defineFeature("a", (r) => {
|
|
16
|
+
r.requires("auth", "tenant");
|
|
17
|
+
});
|
|
18
|
+
const objectForm = defineFeature("b", (r) => {
|
|
19
|
+
r.requires({ features: ["auth", "tenant"] });
|
|
20
|
+
});
|
|
21
|
+
expect(objectForm.requires).toEqual(positional.requires);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("optionalRequires: array-unwrap matches variadic-string form", () => {
|
|
25
|
+
const positional = defineFeature("a", (r) => {
|
|
26
|
+
r.optionalRequires("promotions");
|
|
27
|
+
});
|
|
28
|
+
const objectForm = defineFeature("b", (r) => {
|
|
29
|
+
r.optionalRequires({ features: ["promotions"] });
|
|
30
|
+
});
|
|
31
|
+
expect(objectForm.optionalRequires).toEqual(positional.optionalRequires);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("readsConfig: array-unwrap ({ keys }) matches variadic-string form", () => {
|
|
35
|
+
const positional = defineFeature("a", (r) => {
|
|
36
|
+
r.readsConfig("auth.smtpHost");
|
|
37
|
+
});
|
|
38
|
+
const objectForm = defineFeature("b", (r) => {
|
|
39
|
+
r.readsConfig({ keys: ["auth.smtpHost"] });
|
|
40
|
+
});
|
|
41
|
+
expect(objectForm.configReads).toEqual(positional.configReads);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("entity: split-name ({ name, ...fields }) matches (name, definition) form", () => {
|
|
45
|
+
const definition = { fields: { title: { type: "text" as const, required: true } } };
|
|
46
|
+
const positional = defineFeature("a", (r) => {
|
|
47
|
+
r.entity("item", definition);
|
|
48
|
+
});
|
|
49
|
+
const objectForm = defineFeature("b", (r) => {
|
|
50
|
+
r.entity({ name: "item", ...definition });
|
|
51
|
+
});
|
|
52
|
+
expect(objectForm.entities).toEqual(positional.entities);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("metric: split-name matches (shortName, options) form", () => {
|
|
56
|
+
const positional = defineFeature("a", (r) => {
|
|
57
|
+
r.metric("created", { type: "counter" });
|
|
58
|
+
});
|
|
59
|
+
const objectForm = defineFeature("b", (r) => {
|
|
60
|
+
r.metric({ name: "created", type: "counter" });
|
|
61
|
+
});
|
|
62
|
+
expect(objectForm.metrics).toEqual(positional.metrics);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("secret: split-name matches (shortName, options) form (same feature name for qualifiedName parity)", () => {
|
|
66
|
+
const positional = defineFeature("stripe", (r) => {
|
|
67
|
+
r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
68
|
+
});
|
|
69
|
+
const objectForm = defineFeature("stripe", (r) => {
|
|
70
|
+
r.secret({ name: "apiKey", label: { en: "Stripe API Key" }, scope: "tenant" });
|
|
71
|
+
});
|
|
72
|
+
expect(objectForm.secretKeys).toEqual(positional.secretKeys);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("relation: two-field ({ entity, name, ...def }) matches (entity, name, definition) form", () => {
|
|
76
|
+
const definition = { type: "belongsTo" as const, target: "user", foreignKey: "supplierId" };
|
|
77
|
+
const positional = defineFeature("a", (r) => {
|
|
78
|
+
r.entity("item", { fields: {} });
|
|
79
|
+
r.relation("item", "supplier", definition);
|
|
80
|
+
});
|
|
81
|
+
const objectForm = defineFeature("b", (r) => {
|
|
82
|
+
r.entity("item", { fields: {} });
|
|
83
|
+
r.relation({ entity: "item", name: "supplier", ...definition });
|
|
84
|
+
});
|
|
85
|
+
expect(objectForm.relations).toEqual(positional.relations);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("referenceData: two-field ({ entity, data, ...opts }) matches (entity, data, options) form", () => {
|
|
89
|
+
const data = [{ id: "a", label: "A" }];
|
|
90
|
+
const positional = defineFeature("a", (r) => {
|
|
91
|
+
r.referenceData("category", data, { upsertKey: "id" });
|
|
92
|
+
});
|
|
93
|
+
const objectForm = defineFeature("b", (r) => {
|
|
94
|
+
r.referenceData({ entity: "category", data, upsertKey: "id" });
|
|
95
|
+
});
|
|
96
|
+
expect(objectForm.referenceData).toEqual(positional.referenceData);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("useExtension: two-field ({ name, entity, ...opts }) matches (extensionName, entity, options) form", () => {
|
|
100
|
+
const positional = defineFeature("a", (r) => {
|
|
101
|
+
r.useExtension("audit-log", "item", { verbose: true });
|
|
102
|
+
});
|
|
103
|
+
const objectForm = defineFeature("b", (r) => {
|
|
104
|
+
r.useExtension({ name: "audit-log", entity: "item", verbose: true });
|
|
105
|
+
});
|
|
106
|
+
expect(objectForm.extensionUsages).toEqual(positional.extensionUsages);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("claimKey: generic split-name matches (shortName, options) form", () => {
|
|
110
|
+
const positional = defineFeature("drivers", (r) => {
|
|
111
|
+
r.claimKey("teamId", { type: "string" });
|
|
112
|
+
});
|
|
113
|
+
const objectForm = defineFeature("drivers", (r) => {
|
|
114
|
+
r.claimKey({ name: "teamId", type: "string" });
|
|
115
|
+
});
|
|
116
|
+
expect(objectForm.claimKeys).toEqual(positional.claimKeys);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("job: full-definition object matches (name, options, handler) form", () => {
|
|
120
|
+
const handler = async () => {};
|
|
121
|
+
const positional = defineFeature("a", (r) => {
|
|
122
|
+
r.job("reconcile", { trigger: { manual: true } }, handler);
|
|
123
|
+
});
|
|
124
|
+
const objectForm = defineFeature("b", (r) => {
|
|
125
|
+
r.job({ name: "reconcile", trigger: { manual: true }, handler });
|
|
126
|
+
});
|
|
127
|
+
expect(objectForm.jobs).toEqual(positional.jobs);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("notification: split-name matches (name, definition) form", () => {
|
|
131
|
+
const recipient = () => null;
|
|
132
|
+
const data = () => ({});
|
|
133
|
+
const positional = defineFeature("a", (r) => {
|
|
134
|
+
r.notification("itemLowStock", { trigger: { on: "item:lowStock" }, recipient, data });
|
|
135
|
+
});
|
|
136
|
+
const objectForm = defineFeature("b", (r) => {
|
|
137
|
+
r.notification({ name: "itemLowStock", trigger: { on: "item:lowStock" }, recipient, data });
|
|
138
|
+
});
|
|
139
|
+
expect(objectForm.notifications).toEqual(positional.notifications);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -18,6 +18,20 @@ import type { RequiresApi } from "./types/feature";
|
|
|
18
18
|
// that don't care can keep the default-string and use the wrapper-based
|
|
19
19
|
// strict-mode (string-literal types per call-site) like before.
|
|
20
20
|
|
|
21
|
+
// requires/optionalRequires accept either variadic strings (hand-written
|
|
22
|
+
// call sites) or a single `{ features }` object (the feature-ast renderer's
|
|
23
|
+
// canonical Object-Form for Designer/AI-generated code) — see the matching
|
|
24
|
+
// overloads on RequiresApi / FeatureRegistrar['optionalRequires'].
|
|
25
|
+
function resolveFeatureNamesArgs(
|
|
26
|
+
args: readonly [{ readonly features: readonly string[] }] | readonly string[],
|
|
27
|
+
): readonly string[] {
|
|
28
|
+
const [first] = args;
|
|
29
|
+
if (typeof first === "object" && first !== null && "features" in first) {
|
|
30
|
+
return first.features;
|
|
31
|
+
}
|
|
32
|
+
return args as readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
export function defineFeature<const TName extends string, TExports = undefined>(
|
|
22
36
|
name: TName,
|
|
23
37
|
setup: (r: FeatureRegistrar<TName>) => TExports,
|
|
@@ -40,8 +54,10 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
40
54
|
state.description = text.trim();
|
|
41
55
|
},
|
|
42
56
|
requires: (() => {
|
|
43
|
-
const fn = (
|
|
44
|
-
|
|
57
|
+
const fn = (
|
|
58
|
+
...args: readonly [{ readonly features: readonly string[] }] | readonly string[]
|
|
59
|
+
) => {
|
|
60
|
+
state.requires.push(...resolveFeatureNamesArgs(args));
|
|
45
61
|
};
|
|
46
62
|
fn.projection = (tableName: string) => {
|
|
47
63
|
state.requiredProjections.add(tableName);
|
|
@@ -51,8 +67,10 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
51
67
|
};
|
|
52
68
|
return fn as RequiresApi;
|
|
53
69
|
})(),
|
|
54
|
-
optionalRequires(
|
|
55
|
-
|
|
70
|
+
optionalRequires(
|
|
71
|
+
...args: readonly [{ readonly features: readonly string[] }] | readonly string[]
|
|
72
|
+
): void {
|
|
73
|
+
state.optionalRequires.push(...resolveFeatureNamesArgs(args));
|
|
56
74
|
},
|
|
57
75
|
toggleable(options: { default: boolean }): void {
|
|
58
76
|
if (state.toggleableDefault !== undefined) {
|
|
@@ -86,9 +86,9 @@ defineFeature("todoList", (r) => {
|
|
|
86
86
|
},
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
r.
|
|
89
|
+
r.hook({
|
|
90
90
|
type: "postDelete",
|
|
91
|
-
|
|
91
|
+
target: { allOf: "task" },
|
|
92
92
|
handler: async (event, ctx) => {
|
|
93
93
|
console.log("task deleted");
|
|
94
94
|
},
|
|
@@ -220,7 +220,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
220
220
|
"writeHandler",
|
|
221
221
|
"queryHandler",
|
|
222
222
|
"hook",
|
|
223
|
-
"entityHook",
|
|
224
223
|
"job",
|
|
225
224
|
"notification",
|
|
226
225
|
"authClaims",
|
|
@@ -260,12 +259,14 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
260
259
|
});
|
|
261
260
|
});
|
|
262
261
|
|
|
263
|
-
test("
|
|
264
|
-
const entityHook = result.patterns.find(
|
|
262
|
+
test("hook Pattern: entity-wide { allOf } target aus Object-Form", () => {
|
|
263
|
+
const entityHook = result.patterns.find(
|
|
264
|
+
(p) => p.kind === "hook" && p.hookType === "postDelete",
|
|
265
|
+
);
|
|
265
266
|
expect(entityHook).toMatchObject({
|
|
266
|
-
kind: "
|
|
267
|
+
kind: "hook",
|
|
267
268
|
hookType: "postDelete",
|
|
268
|
-
|
|
269
|
+
target: { allOf: "task" },
|
|
269
270
|
});
|
|
270
271
|
});
|
|
271
272
|
|
|
@@ -71,7 +71,7 @@ defineFeature("todoList", (r) => {
|
|
|
71
71
|
console.log("task saved");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
r.
|
|
74
|
+
r.hook("postDelete", { allOf: "task" }, async (event, ctx) => {
|
|
75
75
|
console.log("task deleted");
|
|
76
76
|
});
|
|
77
77
|
|
|
@@ -136,7 +136,6 @@ describe("parseSourceFile against a complete inline-form feature", () => {
|
|
|
136
136
|
"writeHandler",
|
|
137
137
|
"queryHandler",
|
|
138
138
|
"hook",
|
|
139
|
-
"entityHook",
|
|
140
139
|
"metric",
|
|
141
140
|
"secret",
|
|
142
141
|
"claimKey",
|
|
@@ -68,7 +68,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
68
68
|
{
|
|
69
69
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
70
70
|
expectedFeatureName: "sessions",
|
|
71
|
-
recognisedKinds: ["
|
|
71
|
+
recognisedKinds: ["hook", "writeHandler", "queryHandler"],
|
|
72
72
|
errorMethodNames: ["unmanagedTable", "job"],
|
|
73
73
|
},
|
|
74
74
|
{
|
|
@@ -784,29 +784,43 @@ defineFeature("f", (r) => {
|
|
|
784
784
|
});
|
|
785
785
|
});
|
|
786
786
|
|
|
787
|
-
describe("
|
|
788
|
-
test("captures hookType
|
|
787
|
+
describe("extractHook — entity-wide { allOf } target", () => {
|
|
788
|
+
test("captures hookType and the allOf entity from positional form", () => {
|
|
789
789
|
const result = parseInline(`
|
|
790
790
|
defineFeature("f", (r) => {
|
|
791
|
-
r.
|
|
791
|
+
r.hook("postSave", { allOf: "task" }, (event, ctx) => {});
|
|
792
792
|
});
|
|
793
793
|
`);
|
|
794
794
|
|
|
795
795
|
expect(result.patterns[0]).toMatchObject({
|
|
796
|
-
kind: "
|
|
796
|
+
kind: "hook",
|
|
797
797
|
hookType: "postSave",
|
|
798
|
-
|
|
798
|
+
target: { allOf: "task" },
|
|
799
799
|
});
|
|
800
800
|
});
|
|
801
801
|
|
|
802
|
-
test("
|
|
802
|
+
test("captures hookType and the allOf entity from object form", () => {
|
|
803
803
|
const result = parseInline(`
|
|
804
804
|
defineFeature("f", (r) => {
|
|
805
|
-
r.
|
|
805
|
+
r.hook({ type: "postDelete", target: { allOf: "task" }, handler: () => {} });
|
|
806
806
|
});
|
|
807
807
|
`);
|
|
808
808
|
|
|
809
|
-
expect(result.
|
|
809
|
+
expect(result.patterns[0]).toMatchObject({
|
|
810
|
+
kind: "hook",
|
|
811
|
+
hookType: "postDelete",
|
|
812
|
+
target: { allOf: "task" },
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("rejects a malformed allOf value (not a string/ref)", () => {
|
|
817
|
+
const result = parseInline(`
|
|
818
|
+
defineFeature("f", (r) => {
|
|
819
|
+
r.hook("postSave", { allOf: 123 }, () => {});
|
|
820
|
+
});
|
|
821
|
+
`);
|
|
822
|
+
|
|
823
|
+
expect(result.errors[0]?.methodName).toBe("hook");
|
|
810
824
|
});
|
|
811
825
|
});
|
|
812
826
|
|
|
@@ -157,18 +157,18 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
|
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
test("
|
|
160
|
+
test("addHook routes entity-wide { allOf } target correctly", () => {
|
|
161
161
|
const sf = makeSourceFile(STARTER);
|
|
162
|
-
createFeaturePatcher(sf).
|
|
162
|
+
createFeaturePatcher(sf).addHook({
|
|
163
163
|
type: "postDelete",
|
|
164
|
-
|
|
164
|
+
target: { allOf: "task" },
|
|
165
165
|
handlerSource: "async (event, ctx) => { /* cleanup */ }",
|
|
166
166
|
});
|
|
167
167
|
const result = parseSourceFile(sf);
|
|
168
168
|
expect(result.patterns[0]).toMatchObject({
|
|
169
|
-
kind: "
|
|
169
|
+
kind: "hook",
|
|
170
170
|
hookType: "postDelete",
|
|
171
|
-
|
|
171
|
+
target: { allOf: "task" },
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
@@ -442,9 +442,9 @@ defineFeature("tasks", (r) => {
|
|
|
442
442
|
handlerSource: "async (q, ctx) => []",
|
|
443
443
|
access: { openToAll: true },
|
|
444
444
|
});
|
|
445
|
-
p.
|
|
445
|
+
p.addHook({
|
|
446
446
|
type: "postDelete",
|
|
447
|
-
|
|
447
|
+
target: { allOf: "task" },
|
|
448
448
|
handlerSource: "async (event, ctx) => { /* cascade-clean */ }",
|
|
449
449
|
});
|
|
450
450
|
const result = parseSourceFile(sf);
|
|
@@ -453,7 +453,7 @@ defineFeature("tasks", (r) => {
|
|
|
453
453
|
"entity",
|
|
454
454
|
"writeHandler",
|
|
455
455
|
"queryHandler",
|
|
456
|
-
"
|
|
456
|
+
"hook",
|
|
457
457
|
]);
|
|
458
458
|
});
|
|
459
459
|
});
|