@cosmicdrift/kumiko-framework 0.155.1 → 0.156.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 (74) hide show
  1. package/README.md +9 -38
  2. package/package.json +2 -2
  3. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  4. package/src/__tests__/raw-table.integration.test.ts +17 -40
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +77 -0
  6. package/src/crypto/index.ts +1 -0
  7. package/src/crypto/pii-field-encryption.ts +19 -0
  8. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
  9. package/src/db/__tests__/blind-index.integration.test.ts +16 -0
  10. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  11. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  12. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  13. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  15. package/src/db/collect-table-metas.ts +6 -7
  16. package/src/db/entity-table-meta.ts +1 -1
  17. package/src/db/feature-table-sources.ts +5 -11
  18. package/src/db/migrate-generator.ts +39 -6
  19. package/src/db/queries/shadow-swap.ts +85 -3
  20. package/src/db/tenant-db.ts +8 -0
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  22. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  23. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  24. package/src/engine/__tests__/field-access.test.ts +23 -1
  25. package/src/engine/__tests__/raw-table.test.ts +134 -84
  26. package/src/engine/__tests__/registry.test.ts +40 -0
  27. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  28. package/src/engine/boot-validator/action-wiring.ts +2 -2
  29. package/src/engine/boot-validator/config-deps.ts +35 -0
  30. package/src/engine/boot-validator/index.ts +2 -0
  31. package/src/engine/boot-validator/pii-retention.ts +35 -3
  32. package/src/engine/build-config-feature-schema.ts +3 -3
  33. package/src/engine/config-helpers.ts +2 -0
  34. package/src/engine/define-feature.ts +2 -6
  35. package/src/engine/entity-handlers.ts +2 -4
  36. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  37. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  38. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  39. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  40. package/src/engine/feature-ast/extractors/events.ts +322 -0
  41. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  42. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  43. package/src/engine/feature-ast/extractors/index.ts +32 -24
  44. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  45. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  46. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  47. package/src/engine/feature-ast/parse.ts +3 -3
  48. package/src/engine/feature-ast/render.ts +16 -11
  49. package/src/engine/feature-builder-state.ts +0 -3
  50. package/src/engine/feature-config-events-jobs.ts +3 -6
  51. package/src/engine/feature-entity-handlers.ts +9 -1
  52. package/src/engine/feature-ui-extensions.ts +10 -40
  53. package/src/engine/field-access.ts +13 -2
  54. package/src/engine/object-form.ts +15 -0
  55. package/src/engine/registry-facade.ts +4 -0
  56. package/src/engine/registry-ingest.ts +19 -29
  57. package/src/engine/registry-state.ts +1 -7
  58. package/src/engine/registry-validate.ts +5 -28
  59. package/src/engine/registry.ts +0 -2
  60. package/src/engine/types/config.ts +7 -0
  61. package/src/engine/types/feature.ts +40 -68
  62. package/src/engine/types/fields.ts +6 -0
  63. package/src/engine/types/index.ts +0 -3
  64. package/src/es-ops/README.md +1 -1
  65. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  66. package/src/es-ops/context.ts +4 -2
  67. package/src/es-ops/types.ts +8 -1
  68. package/src/pipeline/msp-rebuild.ts +4 -0
  69. package/src/pipeline/projection-rebuild.ts +35 -0
  70. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  71. package/src/stack/test-stack.ts +17 -2
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  74. package/src/engine/feature-ast/extractors/round4.ts +0 -1227
@@ -709,6 +709,93 @@ defineFeature("f", (r) => {
709
709
  upsertKey: "id",
710
710
  });
711
711
  });
712
+
713
+ test("object form captures entity, data and upsertKey", () => {
714
+ const result = parseInline(`
715
+ defineFeature("f", (r) => {
716
+ r.referenceData({
717
+ entity: "status",
718
+ data: [{ id: "open" }, { id: "closed" }],
719
+ upsertKey: "id",
720
+ });
721
+ });
722
+ `);
723
+
724
+ expect(result.errors).toEqual([]);
725
+ expect(result.patterns[0]).toMatchObject({
726
+ kind: "referenceData",
727
+ entityName: "status",
728
+ data: [{ id: "open" }, { id: "closed" }],
729
+ upsertKey: "id",
730
+ });
731
+ });
732
+
733
+ test("accepts an inline { name: '...' } entity ref, both forms", () => {
734
+ const positional = parseInline(`
735
+ defineFeature("f", (r) => {
736
+ r.referenceData({ name: "status" }, [{ id: "open" }]);
737
+ });
738
+ `);
739
+ expect(positional.patterns[0]).toMatchObject({ kind: "referenceData", entityName: "status" });
740
+
741
+ const objectForm = parseInline(`
742
+ defineFeature("f", (r) => {
743
+ r.referenceData({ entity: { name: "status" }, data: [{ id: "open" }] });
744
+ });
745
+ `);
746
+ expect(objectForm.patterns[0]).toMatchObject({ kind: "referenceData", entityName: "status" });
747
+ });
748
+
749
+ test("emits ParseError when called with no arguments", () => {
750
+ const result = parseInline(`
751
+ defineFeature("f", (r) => {
752
+ r.referenceData();
753
+ });
754
+ `);
755
+
756
+ expect(result.patterns).toEqual([]);
757
+ expect(result.errors[0]?.methodName).toBe("referenceData");
758
+ });
759
+
760
+ test("emits ParseError when object form is missing the data property", () => {
761
+ const result = parseInline(`
762
+ defineFeature("f", (r) => {
763
+ r.referenceData({ entity: "status" });
764
+ });
765
+ `);
766
+
767
+ expect(result.errors[0]?.methodName).toBe("referenceData");
768
+ });
769
+
770
+ test("emits ParseError when object form's data is not an array of plain objects", () => {
771
+ const result = parseInline(`
772
+ defineFeature("f", (r) => {
773
+ r.referenceData({ entity: "status", data: "not-an-array" });
774
+ });
775
+ `);
776
+
777
+ expect(result.errors[0]?.methodName).toBe("referenceData");
778
+ });
779
+
780
+ test("emits ParseError when the positional data argument is missing", () => {
781
+ const result = parseInline(`
782
+ defineFeature("f", (r) => {
783
+ r.referenceData("status");
784
+ });
785
+ `);
786
+
787
+ expect(result.errors[0]?.methodName).toBe("referenceData");
788
+ });
789
+
790
+ test("emits ParseError when positional upsertKey option is not a string", () => {
791
+ const result = parseInline(`
792
+ defineFeature("f", (r) => {
793
+ r.referenceData("status", [{ id: "open" }], { upsertKey: 123 });
794
+ });
795
+ `);
796
+
797
+ expect(result.errors[0]?.methodName).toBe("referenceData");
798
+ });
712
799
  });
713
800
 
714
801
  describe("extractUseExtension", () => {
@@ -740,6 +827,89 @@ defineFeature("f", (r) => {
740
827
  options: { mode: "verbose" },
741
828
  });
742
829
  });
830
+
831
+ test("object form captures name, entity ref and options", () => {
832
+ const result = parseInline(`
833
+ defineFeature("f", (r) => {
834
+ r.useExtension({ name: "audit", entity: "task", options: { mode: "verbose" } });
835
+ });
836
+ `);
837
+
838
+ expect(result.errors).toEqual([]);
839
+ expect(result.patterns[0]).toMatchObject({
840
+ kind: "useExtension",
841
+ extensionName: "audit",
842
+ entityName: "task",
843
+ options: { mode: "verbose" },
844
+ });
845
+ });
846
+
847
+ test("accepts an inline { name: '...' } entity ref, both forms", () => {
848
+ const positional = parseInline(`
849
+ defineFeature("f", (r) => {
850
+ r.useExtension("audit", { name: "task" });
851
+ });
852
+ `);
853
+ expect(positional.patterns[0]).toMatchObject({ kind: "useExtension", entityName: "task" });
854
+
855
+ const objectForm = parseInline(`
856
+ defineFeature("f", (r) => {
857
+ r.useExtension({ name: "audit", entity: { name: "task" } });
858
+ });
859
+ `);
860
+ expect(objectForm.patterns[0]).toMatchObject({ kind: "useExtension", entityName: "task" });
861
+ });
862
+
863
+ test("emits ParseError when called with no arguments", () => {
864
+ const result = parseInline(`
865
+ defineFeature("f", (r) => {
866
+ r.useExtension();
867
+ });
868
+ `);
869
+
870
+ expect(result.patterns).toEqual([]);
871
+ expect(result.errors[0]?.methodName).toBe("useExtension");
872
+ });
873
+
874
+ test("emits ParseError when object form is missing the entity property", () => {
875
+ const result = parseInline(`
876
+ defineFeature("f", (r) => {
877
+ r.useExtension({ name: "audit" });
878
+ });
879
+ `);
880
+
881
+ expect(result.errors[0]?.methodName).toBe("useExtension");
882
+ });
883
+
884
+ test("emits ParseError when object form's options is not a plain object", () => {
885
+ const result = parseInline(`
886
+ defineFeature("f", (r) => {
887
+ r.useExtension({ name: "audit", entity: "task", options: "not-an-object" });
888
+ });
889
+ `);
890
+
891
+ expect(result.errors[0]?.methodName).toBe("useExtension");
892
+ });
893
+
894
+ test("emits ParseError when the positional entity argument is missing", () => {
895
+ const result = parseInline(`
896
+ defineFeature("f", (r) => {
897
+ r.useExtension("audit");
898
+ });
899
+ `);
900
+
901
+ expect(result.errors[0]?.methodName).toBe("useExtension");
902
+ });
903
+
904
+ test("emits ParseError when positional options argument is not a plain object", () => {
905
+ const result = parseInline(`
906
+ defineFeature("f", (r) => {
907
+ r.useExtension("audit", "task", "not-an-object");
908
+ });
909
+ `);
910
+
911
+ expect(result.errors[0]?.methodName).toBe("useExtension");
912
+ });
743
913
  });
744
914
 
745
915
  // =============================================================================
@@ -312,6 +312,37 @@ describe("patch coverage for the remaining pattern-kinds", () => {
312
312
  removePattern(sf, { kind: "defineEvent", eventName: "itemCreated" });
313
313
  expect(parseSourceFile(sf).patterns.find((p) => p.kind === "defineEvent")).toBeUndefined();
314
314
  });
315
+
316
+ test("add + remove via PatternId for nav (id key)", () => {
317
+ const sf = makeSourceFile(STARTER);
318
+ createFeaturePatcher(sf).addNav({
319
+ definition: { id: "items", label: "Items", screen: "inventory:screen:list" },
320
+ });
321
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "nav")).toBeDefined();
322
+ removePattern(sf, { kind: "nav", id: "items" });
323
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "nav")).toBeUndefined();
324
+ });
325
+
326
+ test("add + remove via PatternId for workspace (id key)", () => {
327
+ const sf = makeSourceFile(STARTER);
328
+ createFeaturePatcher(sf).addWorkspace({ definition: { id: "admin", label: "Admin" } });
329
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "workspace")).toBeDefined();
330
+ removePattern(sf, { kind: "workspace", id: "admin" });
331
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "workspace")).toBeUndefined();
332
+ });
333
+
334
+ test("add + remove via PatternId for extendsRegistrar (extensionName key)", () => {
335
+ const sf = makeSourceFile(STARTER);
336
+ addPattern(sf, {
337
+ kind: "extendsRegistrar",
338
+ source: SAMPLE_LOC,
339
+ extensionName: "auditLog",
340
+ defBody: { ...SAMPLE_LOC, raw: "{ hooks: {} }" },
341
+ });
342
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "extendsRegistrar")).toBeDefined();
343
+ removePattern(sf, { kind: "extendsRegistrar", extensionName: "auditLog" });
344
+ expect(parseSourceFile(sf).patterns.find((p) => p.kind === "extendsRegistrar")).toBeUndefined();
345
+ });
315
346
  });
316
347
 
317
348
  describe("singleton-pattern guards", () => {
@@ -448,6 +448,7 @@ describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () =>
448
448
 
449
449
  const DEFINE_EVENT_WITH_MIGRATIONS_FEATURE = `
450
450
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
451
+ import { z } from "zod";
451
452
 
452
453
  defineFeature("billing", (r) => {
453
454
  r.defineEvent("invoicePaid", z.object({ totalCents: z.number() }), {
@@ -456,7 +457,7 @@ defineFeature("billing", (r) => {
456
457
  {
457
458
  fromVersion: 1,
458
459
  toVersion: 2,
459
- transform: (payload) => ({ totalCents: Math.round(payload.total * 100) }),
460
+ transform: (payload: unknown) => ({ totalCents: Math.round((payload as { total: number }).total * 100) }),
460
461
  },
461
462
  ],
462
463
  });
@@ -556,6 +557,10 @@ defineFeature("teeth-check-positive", (r) => {
556
557
  test("STATIC_FEATURE's rendered header-data patterns compile clean", () => {
557
558
  expect(renderAndCompile(STATIC_FEATURE)).toEqual([]);
558
559
  }, 20_000);
560
+
561
+ test("r.defineEvent's rendered migrations array compiles against the real registrar", () => {
562
+ expect(renderAndCompile(DEFINE_EVENT_WITH_MIGRATIONS_FEATURE)).toEqual([]);
563
+ }, 20_000);
559
564
  });
560
565
 
561
566
  // One self-contained, compile-clean fixture per remaining pattern kind not
@@ -0,0 +1,322 @@
1
+ import type { CallExpression, Node, SourceFile } from "ts-morph";
2
+ import { SyntaxKind } from "ts-morph";
3
+ import type { DefineEventPattern, NotificationPattern } from "../patterns";
4
+ import type { SourceLocation } from "../source-location";
5
+ import { sourceLocationFromNode } from "../source-location";
6
+ import {
7
+ type ExtractOutput,
8
+ fail,
9
+ findFunctionLiteral,
10
+ ok,
11
+ readDataLiteralNode,
12
+ readNameOrRef,
13
+ readPropertyKey,
14
+ } from "./shared";
15
+
16
+ // Reads defineEvent's `migrations` option — either the array-of-steps shape
17
+ // used by hand-authored calls (`[{ fromVersion, toVersion, transform }]`,
18
+ // toVersion is redundant on-disk since it is always fromVersion + 1) or the
19
+ // keyed-object canonical shape the Designer renders (`{ "1": transform }`).
20
+ // Keyed by fromVersion (as a string) → the transform closure's location.
21
+ // Skips malformed entries rather than failing the whole defineEvent extract
22
+ // — same "best-effort, degrade gracefully" posture as readDataLiteralNode.
23
+ function extractEventMigrationsFromArray(
24
+ arr: Node,
25
+ sourceFile: SourceFile,
26
+ ): Record<string, SourceLocation> {
27
+ const result: Record<string, SourceLocation> = {};
28
+ const arrLit = arr.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);
29
+ for (const el of arrLit.getElements()) {
30
+ const obj = el.asKind(SyntaxKind.ObjectLiteralExpression);
31
+ if (!obj) continue;
32
+ const fromInit = obj
33
+ .getProperty("fromVersion")
34
+ ?.asKind(SyntaxKind.PropertyAssignment)
35
+ ?.getInitializer();
36
+ const fromVersion = fromInit ? readDataLiteralNode(fromInit) : undefined;
37
+ if (typeof fromVersion !== "number") continue;
38
+ const transformInit = obj
39
+ .getProperty("transform")
40
+ ?.asKind(SyntaxKind.PropertyAssignment)
41
+ ?.getInitializer();
42
+ const fn = transformInit ? findFunctionLiteral(transformInit) : undefined;
43
+ if (!fn) continue;
44
+ result[String(fromVersion)] = sourceLocationFromNode(fn, sourceFile);
45
+ }
46
+ return result;
47
+ }
48
+
49
+ function extractEventMigrationsFromKeyedObject(
50
+ objLit: Node,
51
+ sourceFile: SourceFile,
52
+ ): Record<string, SourceLocation> {
53
+ const result: Record<string, SourceLocation> = {};
54
+ const obj = objLit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
55
+ for (const prop of obj.getProperties()) {
56
+ const propAssign = prop.asKind(SyntaxKind.PropertyAssignment);
57
+ const initializer = propAssign?.getInitializer();
58
+ const fn = initializer ? findFunctionLiteral(initializer) : undefined;
59
+ if (!propAssign || !fn) continue;
60
+ result[readPropertyKey(propAssign)] = sourceLocationFromNode(fn, sourceFile);
61
+ }
62
+ return result;
63
+ }
64
+
65
+ // Reads defineEvent's `migrations` option — either the array-of-steps shape
66
+ // used by hand-authored calls (`[{ fromVersion, toVersion, transform }]`,
67
+ // toVersion is redundant on-disk since it is always fromVersion + 1) or the
68
+ // keyed-object canonical shape the Designer renders (`{ "1": transform }`).
69
+ // Keyed by fromVersion (as a string) → the transform closure's location.
70
+ // Skips malformed entries rather than failing the whole defineEvent extract
71
+ // — same "best-effort, degrade gracefully" posture as readDataLiteralNode.
72
+ function extractEventMigrationsField(
73
+ node: Node,
74
+ sourceFile: SourceFile,
75
+ ): Readonly<Record<string, SourceLocation>> | undefined {
76
+ const result = node.asKind(SyntaxKind.ArrayLiteralExpression)
77
+ ? extractEventMigrationsFromArray(node, sourceFile)
78
+ : node.asKind(SyntaxKind.ObjectLiteralExpression)
79
+ ? extractEventMigrationsFromKeyedObject(node, sourceFile)
80
+ : undefined;
81
+ return result && Object.keys(result).length > 0 ? result : undefined;
82
+ }
83
+
84
+ export function extractDefineEvent(
85
+ call: CallExpression,
86
+ sourceFile: SourceFile,
87
+ ): ExtractOutput<DefineEventPattern> {
88
+ const args = call.getArguments();
89
+ const first = args[0];
90
+ if (!first) {
91
+ return fail(
92
+ "defineEvent",
93
+ sourceLocationFromNode(call, sourceFile),
94
+ "expected at least one argument",
95
+ );
96
+ }
97
+
98
+ const obj = first.asKind(SyntaxKind.ObjectLiteralExpression);
99
+ if (obj && args.length === 1) {
100
+ const nameInit = obj
101
+ .getProperty("name")
102
+ ?.asKind(SyntaxKind.PropertyAssignment)
103
+ ?.getInitializer()
104
+ ?.asKind(SyntaxKind.StringLiteral);
105
+ if (!nameInit) {
106
+ return fail(
107
+ "defineEvent",
108
+ sourceLocationFromNode(call, sourceFile),
109
+ "object form requires a string-literal `name` property",
110
+ );
111
+ }
112
+ const schemaInit = obj
113
+ .getProperty("schema")
114
+ ?.asKind(SyntaxKind.PropertyAssignment)
115
+ ?.getInitializer();
116
+ if (!schemaInit) {
117
+ return fail(
118
+ "defineEvent",
119
+ sourceLocationFromNode(call, sourceFile),
120
+ "object form requires a `schema` property",
121
+ );
122
+ }
123
+ let version: number | undefined;
124
+ const versionInit = obj
125
+ .getProperty("version")
126
+ ?.asKind(SyntaxKind.PropertyAssignment)
127
+ ?.getInitializer();
128
+ if (versionInit) {
129
+ const v = readDataLiteralNode(versionInit);
130
+ if (typeof v === "number") version = v;
131
+ }
132
+ const migrationsInit = obj
133
+ .getProperty("migrations")
134
+ ?.asKind(SyntaxKind.PropertyAssignment)
135
+ ?.getInitializer();
136
+ const migrations = migrationsInit
137
+ ? extractEventMigrationsField(migrationsInit, sourceFile)
138
+ : undefined;
139
+ return ok({
140
+ kind: "defineEvent",
141
+ source: sourceLocationFromNode(call, sourceFile),
142
+ eventName: nameInit.getLiteralValue(),
143
+ schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
144
+ ...(version !== undefined && { version }),
145
+ ...(migrations !== undefined && { migrations }),
146
+ });
147
+ }
148
+
149
+ const nameArg = first.asKind(SyntaxKind.StringLiteral);
150
+ if (!nameArg) {
151
+ return fail(
152
+ "defineEvent",
153
+ sourceLocationFromNode(call, sourceFile),
154
+ "first argument must be a string literal event name (or use the object form)",
155
+ );
156
+ }
157
+ const schemaArg = args[1];
158
+ if (!schemaArg) {
159
+ return fail(
160
+ "defineEvent",
161
+ sourceLocationFromNode(call, sourceFile),
162
+ "expected a Zod schema as second argument",
163
+ );
164
+ }
165
+ let version: number | undefined;
166
+ let migrations: Readonly<Record<string, SourceLocation>> | undefined;
167
+ const optionsArg = args[2];
168
+ const optionsObj = optionsArg?.asKind(SyntaxKind.ObjectLiteralExpression);
169
+ if (optionsObj) {
170
+ const versionInit = optionsObj
171
+ .getProperty("version")
172
+ ?.asKind(SyntaxKind.PropertyAssignment)
173
+ ?.getInitializer();
174
+ if (versionInit) {
175
+ const v = readDataLiteralNode(versionInit);
176
+ if (typeof v === "number") version = v;
177
+ }
178
+ const migrationsInit = optionsObj
179
+ .getProperty("migrations")
180
+ ?.asKind(SyntaxKind.PropertyAssignment)
181
+ ?.getInitializer();
182
+ if (migrationsInit) {
183
+ migrations = extractEventMigrationsField(migrationsInit, sourceFile);
184
+ }
185
+ }
186
+ return ok({
187
+ kind: "defineEvent",
188
+ source: sourceLocationFromNode(call, sourceFile),
189
+ eventName: nameArg.getLiteralValue(),
190
+ schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
191
+ ...(version !== undefined && { version }),
192
+ ...(migrations !== undefined && { migrations }),
193
+ });
194
+ }
195
+
196
+ export function extractNotification(
197
+ call: CallExpression,
198
+ sourceFile: SourceFile,
199
+ ): ExtractOutput<NotificationPattern> {
200
+ const args = call.getArguments();
201
+ const first = args[0];
202
+ if (!first) {
203
+ return fail(
204
+ "notification",
205
+ sourceLocationFromNode(call, sourceFile),
206
+ "expected at least one argument",
207
+ );
208
+ }
209
+
210
+ let nameLiteral: ReturnType<typeof first.asKind<SyntaxKind.StringLiteral>>;
211
+ let defObj: ReturnType<typeof first.asKind<SyntaxKind.ObjectLiteralExpression>>;
212
+
213
+ const firstObj = first.asKind(SyntaxKind.ObjectLiteralExpression);
214
+ if (firstObj && args.length === 1) {
215
+ nameLiteral = firstObj
216
+ .getProperty("name")
217
+ ?.asKind(SyntaxKind.PropertyAssignment)
218
+ ?.getInitializer()
219
+ ?.asKind(SyntaxKind.StringLiteral);
220
+ if (!nameLiteral) {
221
+ return fail(
222
+ "notification",
223
+ sourceLocationFromNode(call, sourceFile),
224
+ "object form requires a string-literal `name` property",
225
+ );
226
+ }
227
+ defObj = firstObj;
228
+ } else {
229
+ nameLiteral = first.asKind(SyntaxKind.StringLiteral);
230
+ if (!nameLiteral) {
231
+ return fail(
232
+ "notification",
233
+ sourceLocationFromNode(call, sourceFile),
234
+ "first argument must be a string literal notification name (or use the object form)",
235
+ );
236
+ }
237
+ defObj = args[1]?.asKind(SyntaxKind.ObjectLiteralExpression);
238
+ if (!defObj) {
239
+ return fail(
240
+ "notification",
241
+ sourceLocationFromNode(call, sourceFile),
242
+ "second argument must be an inline definition object",
243
+ );
244
+ }
245
+ }
246
+ const nameArg = nameLiteral;
247
+ const triggerObj = defObj
248
+ .getProperty("trigger")
249
+ ?.asKind(SyntaxKind.PropertyAssignment)
250
+ ?.getInitializer()
251
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
252
+ if (!triggerObj) {
253
+ return fail(
254
+ "notification",
255
+ sourceLocationFromNode(call, sourceFile),
256
+ "missing or non-object `trigger` property",
257
+ );
258
+ }
259
+ const onInit = triggerObj
260
+ .getProperty("on")
261
+ ?.asKind(SyntaxKind.PropertyAssignment)
262
+ ?.getInitializer();
263
+ const onName = onInit ? readNameOrRef(onInit) : undefined;
264
+ if (!onName) {
265
+ return fail(
266
+ "notification",
267
+ sourceLocationFromNode(call, sourceFile),
268
+ "trigger.on must be a string literal or inline { name } object",
269
+ );
270
+ }
271
+ const recipientInit = defObj
272
+ .getProperty("recipient")
273
+ ?.asKind(SyntaxKind.PropertyAssignment)
274
+ ?.getInitializer();
275
+ const recipientFn = recipientInit ? findFunctionLiteral(recipientInit) : undefined;
276
+ if (!recipientFn) {
277
+ return fail(
278
+ "notification",
279
+ sourceLocationFromNode(call, sourceFile),
280
+ "recipient must be an inline arrow function or function expression",
281
+ );
282
+ }
283
+ const dataInit = defObj
284
+ .getProperty("data")
285
+ ?.asKind(SyntaxKind.PropertyAssignment)
286
+ ?.getInitializer();
287
+ const dataFn = dataInit ? findFunctionLiteral(dataInit) : undefined;
288
+ if (!dataFn) {
289
+ return fail(
290
+ "notification",
291
+ sourceLocationFromNode(call, sourceFile),
292
+ "data must be an inline arrow function or function expression",
293
+ );
294
+ }
295
+ let templates: Record<string, SourceLocation> | undefined;
296
+ const templatesObj = defObj
297
+ .getProperty("templates")
298
+ ?.asKind(SyntaxKind.PropertyAssignment)
299
+ ?.getInitializer()
300
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
301
+ if (templatesObj) {
302
+ templates = {};
303
+ for (const prop of templatesObj.getProperties()) {
304
+ const propAssign = prop.asKind(SyntaxKind.PropertyAssignment);
305
+ if (!propAssign) continue;
306
+ const init = propAssign.getInitializer();
307
+ if (!init) continue;
308
+ const tfn = findFunctionLiteral(init);
309
+ if (!tfn) continue;
310
+ templates[readPropertyKey(propAssign)] = sourceLocationFromNode(tfn, sourceFile);
311
+ }
312
+ }
313
+ return ok({
314
+ kind: "notification",
315
+ source: sourceLocationFromNode(call, sourceFile),
316
+ notificationName: nameArg.getLiteralValue(),
317
+ trigger: { on: onName },
318
+ recipientBody: sourceLocationFromNode(recipientFn, sourceFile),
319
+ dataBody: sourceLocationFromNode(dataFn, sourceFile),
320
+ ...(templates !== undefined && { templates }),
321
+ });
322
+ }