@cosmicdrift/kumiko-framework 0.362.0 → 0.363.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 (108) hide show
  1. package/dist/api/auth-middleware.js +7 -5
  2. package/dist/api/redis-sse-broker.js +10 -8
  3. package/dist/api/routes.js +2 -2
  4. package/dist/bun-db/query.d.ts +6 -4
  5. package/dist/bun-db/query.js +66 -92
  6. package/dist/crypto/kek-source.js +5 -3
  7. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  8. package/dist/crypto/pii-field-encryption.js +2 -2
  9. package/dist/db/apply-entity-event.js +2 -2
  10. package/dist/db/cursor.js +2 -1
  11. package/dist/db/dialect.d.ts +1 -0
  12. package/dist/db/dialect.js +28 -13
  13. package/dist/db/entity-table-from-registry.js +1 -1
  14. package/dist/db/entity-table-meta.js +5 -13
  15. package/dist/db/event-store-executor-context.js +6 -6
  16. package/dist/db/event-store-executor-read.js +9 -9
  17. package/dist/db/migrate-generator.js +5 -3
  18. package/dist/db/migrate-runner.js +31 -35
  19. package/dist/db/parent-ref-clause.js +2 -2
  20. package/dist/db/queries/backfill-pii.js +11 -9
  21. package/dist/db/queries/es-ops.js +1 -1
  22. package/dist/db/queries/event-consumer.d.ts +6 -6
  23. package/dist/db/queries/event-consumer.js +17 -17
  24. package/dist/db/queries/event-store-admin.js +6 -6
  25. package/dist/db/queries/event-store-transfer.js +2 -2
  26. package/dist/db/queries/event-store.d.ts +1 -1
  27. package/dist/db/queries/event-store.js +2 -2
  28. package/dist/db/queries/pii-backfill-state.js +2 -2
  29. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  30. package/dist/db/queries/raw-sql.js +1 -1
  31. package/dist/db/queries/test-stack.js +1 -1
  32. package/dist/db/reference-data.js +3 -5
  33. package/dist/db/table-builder.js +6 -4
  34. package/dist/db/table-introspection.d.ts +16 -0
  35. package/dist/db/table-introspection.js +76 -0
  36. package/dist/db/tenant-db.d.ts +3 -2
  37. package/dist/db/tenant-db.js +4 -10
  38. package/dist/derivatives/variant-key.js +2 -1
  39. package/dist/engine/boot-validator/entity-list-screens.js +1 -1
  40. package/dist/engine/boot-validator/pii-retention.js +1 -1
  41. package/dist/engine/boot-validator/screens.js +2 -1
  42. package/dist/engine/changeset-changes.js +3 -3
  43. package/dist/engine/define-handler.js +3 -6
  44. package/dist/engine/embedded-derived.js +10 -6
  45. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  46. package/dist/engine/factories.js +5 -6
  47. package/dist/engine/feature-ast/extractors/events.js +43 -33
  48. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  49. package/dist/engine/feature-ast/patcher.js +3 -3
  50. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
  51. package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
  52. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  53. package/dist/engine/feature-ast/render.js +6 -3
  54. package/dist/engine/feature-changelog.d.ts +2 -1
  55. package/dist/engine/feature-changelog.js +8 -6
  56. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  57. package/dist/engine/feature-config-events-jobs.js +20 -5
  58. package/dist/engine/feature-ui-extensions.js +2 -3
  59. package/dist/engine/index.d.ts +2 -1
  60. package/dist/engine/index.js +1 -0
  61. package/dist/engine/ownership.js +7 -20
  62. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  63. package/dist/engine/registry-ingest.d.ts +2 -1
  64. package/dist/engine/registry-ingest.js +85 -2
  65. package/dist/engine/registry-validate.d.ts +1 -0
  66. package/dist/engine/registry-validate.js +38 -8
  67. package/dist/engine/registry.js +2 -1
  68. package/dist/engine/schema-builder.js +1 -3
  69. package/dist/engine/soft-delete-cleanup.js +2 -1
  70. package/dist/engine/steps/call-feature.js +1 -3
  71. package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
  72. package/dist/engine/template-variables.d.ts +29 -0
  73. package/dist/engine/template-variables.js +77 -0
  74. package/dist/engine/tier-resolver-extension.js +2 -3
  75. package/dist/engine/types/index.d.ts +2 -2
  76. package/dist/engine/validate-projection-allowlist.js +12 -13
  77. package/dist/engine/where-rule-lint.js +8 -7
  78. package/dist/errors/reasons.d.ts +1 -0
  79. package/dist/errors/reasons.js +2 -0
  80. package/dist/files/file-routes.js +1 -1
  81. package/dist/files/local-provider.js +2 -1
  82. package/dist/i18n/index.d.ts +1 -1
  83. package/dist/i18n/index.js +1 -1
  84. package/dist/i18n/mail-registry.d.ts +2 -0
  85. package/dist/i18n/mail-registry.js +7 -0
  86. package/dist/jobs/job-runner.js +5 -5
  87. package/dist/pipeline/dispatch-query.js +2 -3
  88. package/dist/pipeline/dispatch-shared.js +45 -14
  89. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  90. package/dist/pipeline/index.d.ts +1 -0
  91. package/dist/pipeline/index.js +1 -0
  92. package/dist/pipeline/member-read-only-transaction.js +1 -1
  93. package/dist/pipeline/notify-scope.d.ts +13 -0
  94. package/dist/pipeline/notify-scope.js +32 -0
  95. package/dist/pipeline/system-hooks.js +2 -1
  96. package/dist/pipeline/system-identity-switch.js +2 -2
  97. package/dist/search/meilisearch-adapter.js +3 -4
  98. package/dist/secrets/stored-envelope.js +7 -7
  99. package/dist/stack/push-entity-projection-tables.js +2 -3
  100. package/dist/stack/table-helpers.js +10 -26
  101. package/dist/stack/test-stack.js +2 -5
  102. package/dist/testing/multipart-helper.d.ts +0 -6
  103. package/dist/testing/multipart-helper.js +5 -5
  104. package/dist/testing/without-ambient-temporal.js +4 -4
  105. package/dist/utils/is-plain-object.d.ts +1 -2
  106. package/dist/utils/is-plain-object.js +1 -4
  107. package/package.json +4 -4
  108. package/src/changes.json +44 -0
@@ -1,3 +1,4 @@
1
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  // `personal: { of: "id" }` means the row itself is the subject, not a
2
3
  // referenced user — resolves to `recordOwned` instead of `userOwned`.
3
4
  const RECORD_OWNER_FIELD = "id";
@@ -75,15 +76,13 @@ function isNonEmptyString(value) {
75
76
  // Fail-closed stance gate (kumiko-framework#2921) for the two text-shaped
76
77
  // factories — runs before expandPersonalAnnotations so an untyped JS caller
77
78
  // (no compiler to stop it) can't slip an unannotated text field past the
78
- // type layer either. `overrides` is `unknown` here on purpose: the typed
79
- // signature above already rejects a missing/malformed shape at compile
80
- // time, this re-checks the same shape at the runtime boundary.
79
+ // type layer either. The typed signature above already rejects a
80
+ // missing/malformed shape at compile time; this re-checks the same shape at
81
+ // runtime, hence the nullable parameter.
81
82
  function assertTextFieldStance(factoryName, overrides) {
82
83
  if (overrides === null || typeof overrides !== "object") {
83
84
  throw missingTextFieldStanceError(factoryName);
84
85
  }
85
- // @cast-boundary runtime-guard — re-validated field by field below before
86
- // any value is trusted.
87
86
  const { personal, find, reason } = overrides;
88
87
  if (personal === undefined || personal === null) {
89
88
  throw missingTextFieldStanceError(factoryName);
@@ -106,7 +105,7 @@ function assertTextFieldStance(factoryName, overrides) {
106
105
  throw missingTextFieldStanceError(factoryName);
107
106
  }
108
107
  const validFinds = TEXT_FIELD_STANCE_FIND_VALUES[factoryName];
109
- if (typeof find !== "string" || !validFinds.includes(find)) {
108
+ if (typeof find !== "string" || !isOneOf(validFinds, find)) {
110
109
  throw missingTextFieldStanceError(factoryName);
111
110
  }
112
111
  }
@@ -200,37 +200,45 @@ export function extractNotification(call, sourceFile) {
200
200
  return fail("notification", sourceLocationFromNode(call, sourceFile), "second argument must be an inline definition object");
201
201
  }
202
202
  }
203
- const triggerObj = defObj
204
- .getProperty("trigger")
205
- ?.asKind(SyntaxKind.PropertyAssignment)
206
- ?.getInitializer()
207
- ?.asKind(SyntaxKind.ObjectLiteralExpression);
208
- if (!triggerObj) {
209
- return fail("notification", sourceLocationFromNode(call, sourceFile), "missing or non-object `trigger` property");
210
- }
211
- const onInit = triggerObj
212
- .getProperty("on")
213
- ?.asKind(SyntaxKind.PropertyAssignment)
214
- ?.getInitializer();
215
- const onName = onInit ? readNameOrRef(onInit) : undefined;
216
- if (!onName) {
217
- return fail("notification", sourceLocationFromNode(call, sourceFile), "trigger.on must be a string literal or inline { name } object");
203
+ const triggerProp = defObj.getProperty("trigger");
204
+ if (!triggerProp && (defObj.getProperty("recipient") || defObj.getProperty("data"))) {
205
+ return fail("notification", sourceLocationFromNode(call, sourceFile), "recipient and data require a `trigger`");
218
206
  }
219
- const recipientInit = defObj
220
- .getProperty("recipient")
221
- ?.asKind(SyntaxKind.PropertyAssignment)
222
- ?.getInitializer();
223
- const recipientFn = recipientInit ? findFunctionLiteral(recipientInit) : undefined;
224
- if (!recipientFn) {
225
- return fail("notification", sourceLocationFromNode(call, sourceFile), "recipient must be an inline arrow function or function expression");
226
- }
227
- const dataInit = defObj
228
- .getProperty("data")
229
- ?.asKind(SyntaxKind.PropertyAssignment)
230
- ?.getInitializer();
231
- const dataFn = dataInit ? findFunctionLiteral(dataInit) : undefined;
232
- if (!dataFn) {
233
- return fail("notification", sourceLocationFromNode(call, sourceFile), "data must be an inline arrow function or function expression");
207
+ let onName;
208
+ let recipientFn;
209
+ let dataFn;
210
+ if (triggerProp) {
211
+ const triggerObj = triggerProp
212
+ .asKind(SyntaxKind.PropertyAssignment)
213
+ ?.getInitializer()
214
+ ?.asKind(SyntaxKind.ObjectLiteralExpression);
215
+ if (!triggerObj) {
216
+ return fail("notification", sourceLocationFromNode(call, sourceFile), "non-object `trigger` property");
217
+ }
218
+ const onInit = triggerObj
219
+ .getProperty("on")
220
+ ?.asKind(SyntaxKind.PropertyAssignment)
221
+ ?.getInitializer();
222
+ onName = onInit ? readNameOrRef(onInit) : undefined;
223
+ if (!onName) {
224
+ return fail("notification", sourceLocationFromNode(call, sourceFile), "trigger.on must be a string literal or inline { name } object");
225
+ }
226
+ const recipientInit = defObj
227
+ .getProperty("recipient")
228
+ ?.asKind(SyntaxKind.PropertyAssignment)
229
+ ?.getInitializer();
230
+ recipientFn = recipientInit ? findFunctionLiteral(recipientInit) : undefined;
231
+ if (!recipientFn) {
232
+ return fail("notification", sourceLocationFromNode(call, sourceFile), "recipient must be an inline arrow function or function expression");
233
+ }
234
+ const dataInit = defObj
235
+ .getProperty("data")
236
+ ?.asKind(SyntaxKind.PropertyAssignment)
237
+ ?.getInitializer();
238
+ dataFn = dataInit ? findFunctionLiteral(dataInit) : undefined;
239
+ if (!dataFn) {
240
+ return fail("notification", sourceLocationFromNode(call, sourceFile), "data must be an inline arrow function or function expression");
241
+ }
234
242
  }
235
243
  let templates;
236
244
  const templatesObj = defObj
@@ -257,9 +265,11 @@ export function extractNotification(call, sourceFile) {
257
265
  kind: "notification",
258
266
  source: sourceLocationFromNode(call, sourceFile),
259
267
  notificationName,
260
- trigger: { on: onName },
261
- recipientBody: sourceLocationFromNode(recipientFn, sourceFile),
262
- dataBody: sourceLocationFromNode(dataFn, sourceFile),
268
+ ...(onName !== undefined && { trigger: { on: onName } }),
269
+ ...(recipientFn !== undefined && {
270
+ recipientBody: sourceLocationFromNode(recipientFn, sourceFile),
271
+ }),
272
+ ...(dataFn !== undefined && { dataBody: sourceLocationFromNode(dataFn, sourceFile) }),
263
273
  ...(templates !== undefined && { templates }),
264
274
  });
265
275
  }
@@ -69,11 +69,11 @@ export type AddJobArgs = {
69
69
  };
70
70
  export type AddNotificationArgs = {
71
71
  readonly name: string;
72
- readonly trigger: {
72
+ readonly trigger?: {
73
73
  readonly on: string;
74
74
  };
75
- readonly recipientSource: string;
76
- readonly dataSource: string;
75
+ readonly recipientSource?: string;
76
+ readonly dataSource?: string;
77
77
  readonly templates?: Readonly<Record<string, string>>;
78
78
  };
79
79
  export type AddHttpRouteArgs = {
@@ -205,9 +205,9 @@ export function createFeaturePatcher(sourceFile) {
205
205
  kind: "notification",
206
206
  source: SYNTHETIC_LOC,
207
207
  notificationName: name,
208
- trigger,
209
- recipientBody: rawLoc(recipientSource),
210
- dataBody: rawLoc(dataSource),
208
+ ...(trigger !== undefined && { trigger }),
209
+ ...(recipientSource !== undefined && { recipientBody: rawLoc(recipientSource) }),
210
+ ...(dataSource !== undefined && { dataBody: rawLoc(dataSource) }),
211
211
  ...(templateLocs !== undefined && { templates: templateLocs }),
212
212
  });
213
213
  },
@@ -366,11 +366,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
366
366
  kind: z.ZodLiteral<"notification">;
367
367
  source: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
368
368
  notificationName: z.ZodString;
369
- trigger: z.ZodObject<{
369
+ trigger: z.ZodOptional<z.ZodObject<{
370
370
  on: z.ZodString;
371
- }, z.core.$strict>;
372
- recipientBody: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
373
- dataBody: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
371
+ }, z.core.$strict>>;
372
+ recipientBody: z.ZodOptional<z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>;
373
+ dataBody: z.ZodOptional<z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>;
374
374
  templates: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>>;
375
375
  }, z.core.$strict>;
376
376
  authClaims: z.ZodObject<{
@@ -883,11 +883,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
883
883
  kind: z.ZodLiteral<"notification">;
884
884
  source: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
885
885
  notificationName: z.ZodString;
886
- trigger: z.ZodObject<{
886
+ trigger: z.ZodOptional<z.ZodObject<{
887
887
  on: z.ZodString;
888
- }, z.core.$strict>;
889
- recipientBody: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
890
- dataBody: z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>;
888
+ }, z.core.$strict>>;
889
+ recipientBody: z.ZodOptional<z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>;
890
+ dataBody: z.ZodOptional<z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>;
891
891
  templates: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodType<SourceLocation, unknown, z.core.$ZodTypeInternals<SourceLocation, unknown>>>>;
892
892
  }, z.core.$strict>, z.ZodObject<{
893
893
  kind: z.ZodLiteral<"authClaims">;
@@ -562,9 +562,9 @@ const notificationSchema = z
562
562
  kind: z.literal("notification"),
563
563
  source: sourceFieldSchema,
564
564
  notificationName: z.string(),
565
- trigger: z.object({ on: z.string() }).strict(),
566
- recipientBody: sourceBodySchema,
567
- dataBody: sourceBodySchema,
565
+ trigger: z.object({ on: z.string() }).strict().optional(),
566
+ recipientBody: sourceBodySchema.optional(),
567
+ dataBody: sourceBodySchema.optional(),
568
568
  templates: z.record(z.string(), sourceBodySchema).optional(),
569
569
  })
570
570
  .strict();
@@ -190,11 +190,11 @@ export type NotificationPattern = {
190
190
  readonly kind: "notification";
191
191
  readonly source: SourceLocation;
192
192
  readonly notificationName: string;
193
- readonly trigger: {
193
+ readonly trigger?: {
194
194
  readonly on: string;
195
195
  };
196
- readonly recipientBody: SourceLocation;
197
- readonly dataBody: SourceLocation;
196
+ readonly recipientBody?: SourceLocation;
197
+ readonly dataBody?: SourceLocation;
198
198
  readonly templates?: Readonly<Record<string, SourceLocation>>;
199
199
  };
200
200
  export type AuthClaimsPattern = {
@@ -497,9 +497,12 @@ function renderJob(p) {
497
497
  function renderNotification(p) {
498
498
  const lines = ["r.notification({"];
499
499
  lines.push(` name: ${JSON.stringify(p.notificationName)},`);
500
- lines.push(` trigger: { on: ${JSON.stringify(p.trigger.on)} },`);
501
- lines.push(` recipient: ${p.recipientBody.raw},`);
502
- lines.push(` data: ${p.dataBody.raw},`);
500
+ if (p.trigger)
501
+ lines.push(` trigger: { on: ${JSON.stringify(p.trigger.on)} },`);
502
+ if (p.recipientBody)
503
+ lines.push(` recipient: ${p.recipientBody.raw},`);
504
+ if (p.dataBody)
505
+ lines.push(` data: ${p.dataBody.raw},`);
503
506
  if (p.templates && Object.keys(p.templates).length > 0) {
504
507
  lines.push(" templates: {");
505
508
  for (const [k, loc] of Object.entries(p.templates)) {
@@ -1,4 +1,5 @@
1
- export type ChangelogType = "breaking" | "improvement" | "fix";
1
+ export declare const CHANGELOG_TYPES: readonly ["breaking", "improvement", "fix"];
2
+ export type ChangelogType = (typeof CHANGELOG_TYPES)[number];
2
3
  export type ChangelogEntry = {
3
4
  readonly version: string;
4
5
  readonly type: ChangelogType;
@@ -5,6 +5,8 @@
5
5
  //
6
6
  // File I/O stays in the CLI (`bin/commands/upgrade.ts`) — this module is
7
7
  // pure parse/validate so engine stays off the node:fs allowlist.
8
+ import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
9
+ export const CHANGELOG_TYPES = ["breaking", "improvement", "fix"];
8
10
  /** Parse a changes.json body. Callers own file I/O. */
9
11
  export function parseFeatureChangelog(raw, featureName) {
10
12
  try {
@@ -24,16 +26,16 @@ export function parseFeatureChangelog(raw, featureName) {
24
26
  }
25
27
  }
26
28
  function isChangelogEntry(value) {
27
- if (typeof value !== "object" || value === null)
29
+ if (!isPlainObject(value))
28
30
  return false;
29
- const obj = value;
30
- if (typeof obj["version"] !== "string")
31
+ if (typeof value["version"] !== "string")
31
32
  return false;
32
- if (!["breaking", "improvement", "fix"].includes(obj["type"]))
33
+ if (!isOneOf(CHANGELOG_TYPES, value["type"]))
33
34
  return false;
34
- if (typeof obj["title"] !== "string")
35
+ if (typeof value["title"] !== "string")
35
36
  return false;
36
- if (obj["manualAfterCodemod"] !== undefined && typeof obj["manualAfterCodemod"] !== "boolean") {
37
+ if (value["manualAfterCodemod"] !== undefined &&
38
+ typeof value["manualAfterCodemod"] !== "boolean") {
37
39
  return false;
38
40
  }
39
41
  return true;
@@ -1,7 +1,7 @@
1
1
  import type * as z from "zod";
2
2
  import { type ZodType } from "zod";
3
3
  import type { FeatureBuilderState } from "./feature-builder-state.js";
4
- import type { AuthClaimsFn, ClaimKeyHandle, ClaimKeyType, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigSeedDef, DeclarativeEventMigration, EventDef, EventPiiStance, EventUpcastFn, JobDefinition, JobHandlerFn, MetricOptions, NameOrRef, NotificationDataFn, NotificationRecipientFn, NotificationTemplateFn, QualifiedEventName, SecretKeyHandle, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, TranslationsDef } from "./types/index.js";
4
+ import type { AuthClaimsFn, ClaimKeyHandle, ClaimKeyType, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigSeedDef, DeclarativeEventMigration, EventDef, EventPiiStance, EventUpcastFn, JobDefinition, JobHandlerFn, MetricOptions, NameOrRef, NotificationDataFn, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, QualifiedEventName, SecretKeyHandle, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, TemplateVariables, TranslationsDef } from "./types/index.js";
5
5
  export declare function buildConfigEventsJobsMethods<TName extends string>(state: FeatureBuilderState, name: TName): {
6
6
  config: {
7
7
  <T extends ConfigKeyType>(keyName: string, def: ConfigKeyDefinition<T>): ConfigKeyHandle<T>;
@@ -14,19 +14,29 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
14
14
  notification(notificationNameOrDefinition: string | ({
15
15
  readonly name: string;
16
16
  } & {
17
- readonly trigger: {
17
+ readonly trigger?: {
18
18
  readonly on: NameOrRef;
19
19
  };
20
- readonly recipient: NotificationRecipientFn;
21
- readonly data: NotificationDataFn;
20
+ readonly recipient?: NotificationRecipientFn;
21
+ readonly data?: NotificationDataFn;
22
22
  readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
23
+ readonly variables?: TemplateVariables;
24
+ readonly record?: NotificationRecordMapping;
25
+ readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
26
+ readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
27
+ readonly localizedTemplate?: () => NotificationLocalizedTemplate;
23
28
  }), definition?: {
24
- readonly trigger: {
29
+ readonly trigger?: {
25
30
  readonly on: NameOrRef;
26
31
  };
27
- readonly recipient: NotificationRecipientFn;
28
- readonly data: NotificationDataFn;
32
+ readonly recipient?: NotificationRecipientFn;
33
+ readonly data?: NotificationDataFn;
29
34
  readonly templates?: Readonly<Record<string, NotificationTemplateFn>>;
35
+ readonly variables?: TemplateVariables;
36
+ readonly record?: NotificationRecordMapping;
37
+ readonly template?: Readonly<Record<string, NotificationTemplateBody>>;
38
+ readonly meta?: Readonly<Record<string, NotificationTemplateMeta>>;
39
+ readonly localizedTemplate?: () => NotificationLocalizedTemplate;
30
40
  }): void;
31
41
  translations(def: TranslationsDef): void;
32
42
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
@@ -1,4 +1,5 @@
1
1
  import { escapeHatchGrantsProblem, normalizeEventPiiSubject, } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { ZodObject } from "zod";
3
4
  import { isValidIanaTimeZone } from "../time/iana.js";
4
5
  import { resolveName } from "./handler-helpers.js";
@@ -197,10 +198,25 @@ export function buildConfigEventsJobsMethods(state, name) {
197
198
  : splitNamedDefinition(notificationNameOrDefinition);
198
199
  state.notifications[notificationName] = {
199
200
  name: notificationName,
200
- trigger: { on: resolveName(resolvedDefinition.trigger.on) },
201
- recipient: resolvedDefinition.recipient,
202
- data: resolvedDefinition.data,
201
+ ...(resolvedDefinition.trigger !== undefined
202
+ ? { trigger: { on: resolveName(resolvedDefinition.trigger.on) } }
203
+ : {}),
204
+ ...(resolvedDefinition.recipient !== undefined
205
+ ? { recipient: resolvedDefinition.recipient }
206
+ : {}),
207
+ ...(resolvedDefinition.data !== undefined ? { data: resolvedDefinition.data } : {}),
203
208
  templates: resolvedDefinition.templates,
209
+ ...(resolvedDefinition.variables !== undefined
210
+ ? { variables: resolvedDefinition.variables }
211
+ : {}),
212
+ ...(resolvedDefinition.record !== undefined ? { record: resolvedDefinition.record } : {}),
213
+ ...(resolvedDefinition.template !== undefined
214
+ ? { template: resolvedDefinition.template }
215
+ : {}),
216
+ ...(resolvedDefinition.meta !== undefined ? { meta: resolvedDefinition.meta } : {}),
217
+ ...(resolvedDefinition.localizedTemplate !== undefined
218
+ ? { localizedTemplate: resolvedDefinition.localizedTemplate }
219
+ : {}),
204
220
  };
205
221
  },
206
222
  translations(def) {
@@ -366,10 +382,9 @@ function compileEventMigration(spec) {
366
382
  renameTargets.set(to, from);
367
383
  }
368
384
  return (payload) => {
369
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
385
+ if (!isPlainObject(payload)) {
370
386
  throw new Error("Declarative event migration expects an object payload");
371
387
  }
372
- // @cast-boundary parse — payload is guarded as a plain object above
373
388
  const next = { ...payload };
374
389
  for (const [from, to] of Object.entries(spec.rename ?? {})) {
375
390
  if (from in next) {
@@ -1,4 +1,5 @@
1
1
  import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
3
4
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
4
5
  import { LifecycleHookTypes } from "./constants.js";
@@ -209,9 +210,7 @@ export function buildUiExtensionsMethods(state, name) {
209
210
  // for every extension regardless of its typed hook shape.
210
211
  const escapeHatch = optionsBag?.["escapeHatch"];
211
212
  if (escapeHatch !== undefined) {
212
- const reason = typeof escapeHatch === "object" && escapeHatch !== null
213
- ? escapeHatch.reason
214
- : undefined;
213
+ const reason = isPlainObject(escapeHatch) ? escapeHatch["reason"] : undefined;
215
214
  if (typeof reason !== "string" || reason.trim().length === 0) {
216
215
  throw new Error(`[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ` +
217
216
  `must be { reason: "<non-empty string>" } explaining why this usage needs unfiltered db access.`);
@@ -77,10 +77,11 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
77
77
  export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
78
78
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
79
79
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
80
+ export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
80
81
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
81
82
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
82
83
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
83
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
84
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
84
85
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
85
86
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
86
87
  export { runValidation } from "./validation.js";
@@ -70,6 +70,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
70
70
  export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
71
71
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
72
72
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
73
+ export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
73
74
  export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
74
75
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, } from "./transfer-adjacency.js";
75
76
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
@@ -16,8 +16,9 @@
16
16
  //
17
17
  // Construction: use the `from(ref, column?)` helper. It returns a FromRule
18
18
  // ready to drop into an access map.
19
- import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
19
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
20
20
  import { toSnakeCase } from "../db/table-builder.js";
21
+ import { columnSqlNameOf, schemaTableName } from "../db/table-introspection.js";
21
22
  import { assertQualifiedWhereFragment, tableColumnSqlNames } from "./where-rule-lint.js";
22
23
  // Parse an OwnershipRef into kind + resolved path + default column.
23
24
  // Throws on malformed input so the error surfaces at `from()`-call-site (in
@@ -185,32 +186,18 @@ export function userCanCreateFieldRow(user, accessMap, newRow) {
185
186
  const PASS_CLAUSE = { kind: "pass" };
186
187
  const EMPTY_CLAUSE = { kind: "empty" };
187
188
  export function tableNameOf(table) {
188
- if (table !== null && typeof table === "object") {
189
- const sym = table[KUMIKO_NAME_SYMBOL];
190
- if (typeof sym === "string")
191
- return sym;
192
- }
193
- return "<unknown>";
189
+ return schemaTableName(table) ?? "<unknown>";
194
190
  }
195
191
  // Resolve a JS-field name on the table to its underlying SQL column name.
196
- // Drizzle tables carry the mapping under Symbol.for("kumiko:schema:Columns");
197
- // we read it without importing drizzle-orm at runtime.
198
192
  function columnSqlName(table, field) {
199
193
  if (table === null || typeof table !== "object")
200
194
  return null;
201
- const cols = table[KUMIKO_COLUMNS_SYMBOL];
202
- if (cols && typeof cols === "object") {
203
- const col = cols[field];
204
- if (col && typeof col === "object") {
205
- const nameVal = col["name"];
206
- if (typeof nameVal === "string")
207
- return nameVal;
208
- }
209
- }
195
+ const handleName = columnSqlNameOf(table, field);
196
+ if (handleName !== undefined)
197
+ return handleName;
210
198
  // Field may already be the SQL column name on plain objects (tests, etc.).
211
- if (table[field] !== undefined) {
199
+ if (isPlainObject(table) && table[field] !== undefined)
212
200
  return toSnakeCase(field);
213
- }
214
201
  return null;
215
202
  }
216
203
  function quoteIdent(name) {
@@ -351,7 +351,6 @@ export const notificationSchema = {
351
351
  path: "trigger.on",
352
352
  label: { en: "Trigger on entity", de: "Trigger-Entität" },
353
353
  input: "entity-ref",
354
- required: true,
355
354
  },
356
355
  {
357
356
  path: "recipientBody",
@@ -1,8 +1,9 @@
1
1
  import type { RegistryState } from "./registry-state.js";
2
- import type { FeatureDefinition } from "./types/index.js";
2
+ import type { FeatureDefinition, NotificationDefinition } from "./types/index.js";
3
3
  export declare function populateFeatureCore(state: RegistryState, feature: FeatureDefinition): void;
4
4
  export declare function populateHandlers(state: RegistryState, feature: FeatureDefinition): void;
5
5
  export declare function populateConfigKeys(state: RegistryState, feature: FeatureDefinition): void;
6
+ export declare function validateNotificationTemplate(featureName: string, notificationName: string, notifDef: NotificationDefinition): void;
6
7
  export declare function populateJobsAndNotifications(state: RegistryState, feature: FeatureDefinition): void;
7
8
  export declare function populateEvents(state: RegistryState, feature: FeatureDefinition): void;
8
9
  export declare function populateSystemEvents(state: RegistryState): void;
@@ -90,6 +90,86 @@ export function populateConfigKeys(state, feature) {
90
90
  state.configKeyMap.set(qualifiedKey, keyDef);
91
91
  }
92
92
  }
93
+ // Mirrors isDocumentReferenceName in bundled-features/template-resolver: the
94
+ // framework cannot import bundled-features, and both sides must accept the
95
+ // same placeholder names.
96
+ const TEMPLATE_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
97
+ const FORBIDDEN_TEMPLATE_VARIABLE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
98
+ // The first four mirror SYSTEM_PLACEHOLDERS in bundled-features/template-resolver: the
99
+ // renderer fills these itself, so a declared variable of the same name would
100
+ // be shadowed or shadow them.
101
+ const RESERVED_TEMPLATE_VARIABLE_NAMES = new Set([
102
+ "tenantName",
103
+ "signature",
104
+ "imprint",
105
+ "unsubscribeUrl",
106
+ // Handlebars helpers of the mail renderer win over context values of the
107
+ // same name, so a variable called like a helper would never render.
108
+ "link",
109
+ "money",
110
+ "date",
111
+ ]);
112
+ const KEBAB_NOTIFICATION_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
113
+ function isTemplateVariableName(value) {
114
+ return (TEMPLATE_VARIABLE_NAME.test(value) &&
115
+ !value.split(".").some((segment) => FORBIDDEN_TEMPLATE_VARIABLE_SEGMENTS.has(segment)));
116
+ }
117
+ function notificationLabel(featureName, notificationName) {
118
+ return `Notification "${featureName}:notify:${notificationName}"`;
119
+ }
120
+ function assertTriggerGroupComplete(label, notifDef) {
121
+ if (notifDef.trigger !== undefined) {
122
+ const missing = [
123
+ notifDef.recipient === undefined ? "recipient" : undefined,
124
+ notifDef.data === undefined ? "data" : undefined,
125
+ ].filter((name) => name !== undefined);
126
+ if (missing.length > 0) {
127
+ throw new Error(`${label} declares a trigger but no ${missing.join(" and ")}: trigger, recipient and data belong together`);
128
+ }
129
+ }
130
+ }
131
+ // localizedTemplate exists because translations are only complete once every
132
+ // locale feature is built; the registry resolves it into the static template
133
+ // + meta that all downstream consumers (seeding, validation) already read.
134
+ function resolveLocalizedTemplate(featureName, notificationName, notifDef) {
135
+ const { localizedTemplate, ...rest } = notifDef;
136
+ if (localizedTemplate === undefined)
137
+ return rest;
138
+ if (rest.template !== undefined || rest.meta !== undefined) {
139
+ throw new Error(`${notificationLabel(featureName, notificationName)} declares localizedTemplate together with template/meta: pick one`);
140
+ }
141
+ const byLocale = Object.entries(localizedTemplate());
142
+ return {
143
+ ...rest,
144
+ template: Object.fromEntries(byLocale.map(([locale, entry]) => [locale, { body: entry.body }])),
145
+ meta: Object.fromEntries(byLocale.map(([locale, entry]) => [locale, entry.meta])),
146
+ };
147
+ }
148
+ export function validateNotificationTemplate(featureName, notificationName, notifDef) {
149
+ // skip: a notification without a template has nothing to validate
150
+ if (notifDef.template === undefined)
151
+ return;
152
+ const where = notificationLabel(featureName, notificationName);
153
+ if (!KEBAB_NOTIFICATION_NAME.test(notificationName)) {
154
+ throw new Error(`${where} declares a template, so its name must be kebab-case (lowercase, digits, dashes): the name becomes the template slug`);
155
+ }
156
+ for (const locale of Object.keys(notifDef.template)) {
157
+ if (notifDef.meta?.[locale]?.subject === undefined) {
158
+ throw new Error(`${where} has a template for locale "${locale}" but no meta.${locale}.subject`);
159
+ }
160
+ }
161
+ for (const [name, def] of Object.entries(notifDef.variables ?? {})) {
162
+ if (RESERVED_TEMPLATE_VARIABLE_NAMES.has(name)) {
163
+ throw new Error(`${where} declares variable "${name}" which collides with a system placeholder (${[...RESERVED_TEMPLATE_VARIABLE_NAMES].join(", ")})`);
164
+ }
165
+ const names = def.type === "list" ? [name, ...Object.keys(def.item)] : [name];
166
+ for (const candidate of names) {
167
+ if (!isTemplateVariableName(candidate)) {
168
+ throw new Error(`${where} declares variable "${candidate}" which is not a valid placeholder name (letters, digits, underscore; no prototype segments)`);
169
+ }
170
+ }
171
+ }
172
+ }
93
173
  // Jobs (runIn-pinned, duplicate-guarded) + notifications (trigger resolved later).
94
174
  export function populateJobsAndNotifications(state, feature) {
95
175
  // Jobs: scope:job:name
@@ -115,10 +195,13 @@ export function populateJobsAndNotifications(state, feature) {
115
195
  // Notifications: scope:notify:name
116
196
  for (const [name, notifDef] of Object.entries(feature.notifications ?? {})) {
117
197
  const qualifiedName = qualify(feature.name, "notify", name);
198
+ assertTriggerGroupComplete(notificationLabel(feature.name, name), notifDef);
199
+ const resolvedDef = resolveLocalizedTemplate(feature.name, name, notifDef);
200
+ validateNotificationTemplate(feature.name, name, resolvedDef);
118
201
  state.notificationMap.set(qualifiedName, {
119
- ...notifDef,
202
+ ...resolvedDef,
120
203
  name: qualifiedName,
121
- trigger: { on: notifDef.trigger.on },
204
+ ...(resolvedDef.trigger !== undefined ? { trigger: { on: resolvedDef.trigger.on } } : {}),
122
205
  });
123
206
  state.notificationFeatureMap.set(qualifiedName, feature.name);
124
207
  }
@@ -18,6 +18,7 @@ export declare function buildEntityEventUpcasterChains(state: RegistryState): vo
18
18
  export declare function buildEventUpcasterChains(state: RegistryState, features: readonly FeatureDefinition[]): void;
19
19
  export declare function validateProjectionApplyKeys(state: RegistryState): void;
20
20
  export declare function validateRequiredFeatures(state: RegistryState, features: readonly FeatureDefinition[]): void;
21
+ export declare function validateNotificationRecordMappings(state: RegistryState): void;
21
22
  export declare function resolveNotificationTriggersAndRegisterHooks(state: RegistryState): void;
22
23
  export declare function validateLifecycleHookTargets(state: RegistryState): void;
23
24
  export declare function validateEntityHookTargets(state: RegistryState, features: readonly FeatureDefinition[]): void;