@cosmicdrift/kumiko-framework 0.350.0 → 0.352.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 (45) hide show
  1. package/dist/bun-db/query.js +3 -3
  2. package/dist/db/dialect.d.ts +1 -1
  3. package/dist/db/dialect.js +4 -3
  4. package/dist/db/located-timestamp.js +1 -1
  5. package/dist/db/queries/seed-context.d.ts +17 -0
  6. package/dist/db/queries/seed-context.js +26 -0
  7. package/dist/db/table-builder.d.ts +5 -5
  8. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  9. package/dist/engine/boot-validator/entity-handler.js +17 -0
  10. package/dist/engine/boot-validator/index.js +2 -1
  11. package/dist/engine/boot-validator/query-output-columns.js +3 -3
  12. package/dist/engine/boot-validator/query-refs.js +11 -7
  13. package/dist/engine/boot-validator/screens.js +89 -9
  14. package/dist/engine/build-app-schema.js +3 -0
  15. package/dist/engine/steps/_duration-utils.js +1 -1
  16. package/dist/engine/types/index.d.ts +1 -1
  17. package/dist/es-ops/context.js +15 -1
  18. package/dist/es-ops/index.d.ts +1 -1
  19. package/dist/es-ops/types.d.ts +20 -0
  20. package/dist/event-store/event-store.d.ts +1 -0
  21. package/dist/event-store/event-store.js +4 -17
  22. package/dist/event-store/index.d.ts +1 -0
  23. package/dist/event-store/index.js +1 -0
  24. package/dist/event-store/tenant-db-append.d.ts +8 -0
  25. package/dist/event-store/tenant-db-append.js +16 -0
  26. package/dist/i18n/required-surface-keys.js +7 -1
  27. package/dist/pipeline/dispatch-batch.js +5 -1
  28. package/dist/pipeline/dispatch-write.js +36 -82
  29. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -0
  30. package/dist/pipeline/event-dispatcher-delivery.js +3 -10
  31. package/dist/pipeline/pre-handler-gates.d.ts +9 -0
  32. package/dist/pipeline/pre-handler-gates.js +104 -0
  33. package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
  34. package/dist/pipeline/pre-transaction-reservations.js +34 -21
  35. package/dist/rate-limit/resolver.js +3 -6
  36. package/dist/time/legacy-date.d.ts +1 -1
  37. package/dist/time/legacy-date.js +1 -1
  38. package/dist/time/polyfill.d.ts +1 -0
  39. package/dist/time/polyfill.js +14 -33
  40. package/dist/time/tz-context.d.ts +1 -1
  41. package/dist/time/tz-context.js +8 -11
  42. package/dist/ui-types/index.d.ts +1 -1
  43. package/dist/upgrade-cli.js +39 -35
  44. package/package.json +4 -4
  45. package/src/changes.json +27 -0
@@ -19,9 +19,9 @@
19
19
  // drizzle's getTableName + getTableColumns (drizzle kept only as a type
20
20
  // reference, never as a runtime API call)
21
21
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
22
- // Static polyfill import (not getTemporal()): coercion results stay on one
23
- // Temporal implementation repo-wide, even when Bun exposes a global (#1480).
24
- import { Temporal } from "temporal-polyfill";
22
+ // Static import (not getTemporal()): coercion works without an installed
23
+ // global and returns the same Temporal classes as the rest of the repo (#1480).
24
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
27
27
  import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
@@ -1,5 +1,5 @@
1
1
  import { type ColumnHandle, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
- import { Temporal } from "temporal-polyfill";
2
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import type { PgType } from "./entity-table-meta.js";
4
4
  export type { ColumnHandle, SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
5
5
  export type TableColumns<_T = any> = SchemaTable;
@@ -13,10 +13,11 @@
13
13
  // The framework no longer imports drizzle-orm at runtime — schema-files
14
14
  // use only this module.
15
15
  //
16
- // Static import, not the ambient global: Bun doesn't expose Temporal on
17
- // globalThis, so instantToDriver crashed on timestamptz writes (#1480).
16
+ // Static import of the shared Temporal module, not the ambient global: the
17
+ // global may be absent (Hermes/Safari) or torn down in tests, and instantToDriver
18
+ // crashed on timestamptz writes without it (#1480).
18
19
  import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
19
- import { Temporal } from "temporal-polyfill";
20
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
20
21
  function isNumericPgType(t) {
21
22
  return t.startsWith("numeric(");
22
23
  }
@@ -11,7 +11,7 @@
11
11
  // Static import, not the ambient global: Bun doesn't expose Temporal on
12
12
  // globalThis, so this crashed with "Temporal is not defined" outside boot
13
13
  // paths that install it (#1480).
14
- import { Temporal } from "temporal-polyfill";
14
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
15
15
  // Sprint F: <name>Utc-Spalte ist jetzt instant() (siehe dialect.ts) —
16
16
  // Drizzle gibt direkt Temporal.Instant zurück. Vor Sprint F kam ein PG-
17
17
  // Wire-Format-String "2026-04-15 09:00:00+00" rein der via String-Massage
@@ -1,3 +1,4 @@
1
+ import type { DbRunner } from "../connection.js";
1
2
  import type { AnyDb } from "../query.js";
2
3
  export type SeedUserRow = {
3
4
  readonly id: string;
@@ -18,3 +19,19 @@ export type SeedTenantDbRow = {
18
19
  export declare function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null>;
19
20
  export declare function selectMembershipsOfUser(db: AnyDb, userId: string): Promise<readonly SeedMembershipDbRow[]>;
20
21
  export declare function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]>;
22
+ export type SeedTemplateResourceDbRow = {
23
+ readonly id: string;
24
+ readonly tenant_id: string;
25
+ readonly slug: string;
26
+ readonly kind: string;
27
+ readonly locale: string;
28
+ readonly status: string;
29
+ };
30
+ export type SeedTemplateResourceDbFilter = {
31
+ readonly tenantId: string;
32
+ readonly slug?: string;
33
+ readonly kind?: string;
34
+ readonly status?: string;
35
+ readonly locale?: string;
36
+ };
37
+ export declare function selectTemplateResources(db: DbRunner, filter: SeedTemplateResourceDbFilter): Promise<readonly SeedTemplateResourceDbRow[]>;
@@ -1,4 +1,6 @@
1
1
  import { unsafeReadRetrying } from "../query.js";
2
+ import { tableExists } from "../schema-inspection.js";
3
+ import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
2
4
  export async function selectUserByEmail(db, email) {
3
5
  const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
4
6
  FROM read_users
@@ -23,3 +25,27 @@ export async function selectAllTenants(db) {
23
25
  FROM read_tenants
24
26
  ORDER BY inserted_at`, []);
25
27
  }
28
+ // Column names are fixed fragments; every filter value is a bound parameter.
29
+ // template-resolver is an optional feature — without its table the answer is
30
+ // "no templates", not an error.
31
+ export async function selectTemplateResources(db, filter) {
32
+ if (!(await tableExists(db, "read_template_resources")))
33
+ return [];
34
+ const conditions = ["tenant_id = $1", SOFT_DELETE_LIVE_ROW_PREDICATE];
35
+ const params = [filter.tenantId];
36
+ for (const [column, value] of [
37
+ ["slug", filter.slug],
38
+ ["kind", filter.kind],
39
+ ["status", filter.status],
40
+ ["locale", filter.locale],
41
+ ]) {
42
+ if (value === undefined)
43
+ continue;
44
+ params.push(value);
45
+ conditions.push(`${column} = $${params.length}`);
46
+ }
47
+ return unsafeReadRetrying(db, `SELECT id::text AS id, tenant_id::text AS tenant_id, slug, kind, locale, status
48
+ FROM read_template_resources
49
+ WHERE ${conditions.join(" AND ")}
50
+ ORDER BY slug, locale`, params);
51
+ }
@@ -167,20 +167,20 @@ export declare function buildBaseColumns(softDelete: boolean, idType?: "serial"
167
167
  id: ColumnBuilder<string> | ColumnBuilder<number>;
168
168
  tenantId: ColumnBuilder<string>;
169
169
  version: ColumnBuilder<number>;
170
- insertedAt: ColumnBuilder<import("temporal-spec").Temporal.Instant>;
171
- modifiedAt: ColumnBuilder<import("temporal-spec").Temporal.Instant>;
170
+ insertedAt: ColumnBuilder<Temporal.Instant>;
171
+ modifiedAt: ColumnBuilder<Temporal.Instant>;
172
172
  insertedById: ColumnBuilder<string>;
173
173
  modifiedById: ColumnBuilder<string>;
174
174
  } | {
175
175
  id: ColumnBuilder<string> | ColumnBuilder<number>;
176
176
  tenantId: ColumnBuilder<string>;
177
177
  version: ColumnBuilder<number>;
178
- insertedAt: ColumnBuilder<import("temporal-spec").Temporal.Instant>;
179
- modifiedAt: ColumnBuilder<import("temporal-spec").Temporal.Instant>;
178
+ insertedAt: ColumnBuilder<Temporal.Instant>;
179
+ modifiedAt: ColumnBuilder<Temporal.Instant>;
180
180
  insertedById: ColumnBuilder<string>;
181
181
  modifiedById: ColumnBuilder<string>;
182
182
  isDeleted: ColumnBuilder<boolean>;
183
- deletedAt: ColumnBuilder<import("temporal-spec").Temporal.Instant>;
183
+ deletedAt: ColumnBuilder<Temporal.Instant>;
184
184
  deletedById: ColumnBuilder<string>;
185
185
  };
186
186
  export declare function rowMetaFieldNames(softDelete: boolean): ReadonlySet<string>;
@@ -19,6 +19,7 @@ export declare function validateFileFields(feature: FeatureDefinition): boolean;
19
19
  export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
20
20
  export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
21
21
  export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
22
+ export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
22
23
  export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
23
24
  export declare function validateImageVariants(feature: FeatureDefinition): void;
24
25
  export declare function validateTransitions(feature: FeatureDefinition): void;
@@ -692,6 +692,23 @@ function assertNoSelectOptionsQuery(field, path, featureName) {
692
692
  throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
693
693
  }
694
694
  }
695
+ // A typo'd availability QN would only surface as a failed query when the form
696
+ // mounts, so it is pinned at boot like the other option queries.
697
+ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
698
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
699
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
700
+ if (field.type !== "select" || field.optionsAvailabilityQuery === undefined)
701
+ continue;
702
+ const query = field.optionsAvailabilityQuery;
703
+ if (query.length === 0 || !queryHandlers.has(query)) {
704
+ throw new Error(`[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}" ` +
705
+ `declares optionsAvailabilityQuery "${query}" which is not a registered query-handler. ` +
706
+ `Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
707
+ `is declared via r.queryHandler(...).`);
708
+ }
709
+ }
710
+ }
711
+ }
695
712
  export function validateMultiSelectFields(feature) {
696
713
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
697
714
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -9,7 +9,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
9
9
  import { validateFeatureBootChecks } from "./boot-check.js";
10
10
  import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
11
11
  import { collectDetailForScreens } from "./detail-screens.js";
12
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
12
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
13
13
  import { validateEntityListScreens } from "./entity-list-screens.js";
14
14
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
15
15
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
@@ -169,6 +169,7 @@ export function validateBoot(rawFeatures, options) {
169
169
  validateRecordOwnedSubjects(feature);
170
170
  validateApiExposureMatching(feature, allExposedApis, featureMap);
171
171
  validateEntitySelectOptionsQuery(feature);
172
+ validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
172
173
  validateEmbeddedFields(feature, featureMap, queryHandlerQns);
173
174
  validateMultiSelectFields(feature);
174
175
  validateImageVariants(feature);
@@ -175,6 +175,9 @@ function checkVisibleWhenField(queryHandlers, visibleWhen, owner) {
175
175
  }
176
176
  function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
177
177
  for (const panel of screen.panels) {
178
+ if (panel.kind !== "custom") {
179
+ checkVisibleWhenField(queryHandlers, panel.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}"`);
180
+ }
178
181
  if (panel.kind === "stat") {
179
182
  checkDashboardStatPanelFields(queryHandlers, featureName, screenId, panel);
180
183
  }
@@ -183,9 +186,6 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
183
186
  checkDashboardStatPanelFields(queryHandlers, featureName, screenId, stat);
184
187
  }
185
188
  }
186
- else if (panel.kind === "screen") {
187
- checkVisibleWhenField(queryHandlers, panel.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`);
188
- }
189
189
  else if (panel.kind === "list") {
190
190
  const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
191
191
  for (const column of panel.columns) {
@@ -26,12 +26,11 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
26
26
  for (const panel of screen.panels) {
27
27
  if (panel.kind === "custom")
28
28
  continue;
29
- if (panel.kind === "screen") {
30
- if (panel.visibleWhen !== undefined) {
31
- checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}" visibleWhen`);
32
- }
33
- continue;
29
+ if (panel.visibleWhen !== undefined) {
30
+ checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
34
31
  }
32
+ if (panel.kind === "screen")
33
+ continue;
35
34
  if (panel.kind === "stat-group") {
36
35
  for (const stat of panel.stats) {
37
36
  checkQueryRef(queryHandlers, stat.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}"`);
@@ -46,9 +45,14 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
46
45
  }
47
46
  function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
48
47
  for (const [fieldName, field] of Object.entries(fields)) {
49
- if (field.type !== "select" || field.optionsQuery === undefined)
48
+ if (field.type !== "select")
50
49
  continue;
51
- checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
50
+ if (field.optionsQuery !== undefined) {
51
+ checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
52
+ }
53
+ if (field.optionsAvailabilityQuery !== undefined) {
54
+ checkQueryRef(queryHandlers, field.optionsAvailabilityQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsAvailabilityQuery`);
55
+ }
52
56
  }
53
57
  }
54
58
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
@@ -493,7 +493,8 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
493
493
  }
494
494
  }
495
495
  // Static options XOR optionsQuery on inline-form select fields, and `{ field }`
496
- // payload refs must name another field of the same form. The QN existence check
496
+ // payload refs must name another field of the same form (the payload also feeds
497
+ // optionsAvailabilityQuery). The QN existence check
497
498
  // lives in query-refs.ts with the other query refs.
498
499
  function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
499
500
  if (fdef.type === "select") {
@@ -507,6 +508,12 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
507
508
  }
508
509
  validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
509
510
  }
511
+ else if (fdef.optionsAvailabilityQuery !== undefined) {
512
+ if (fdef.optionsAvailabilityQuery.length === 0) {
513
+ throw new Error(`${where} has an empty optionsAvailabilityQuery`);
514
+ }
515
+ validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
516
+ }
510
517
  else if (fdef.optionsQueryPayload !== undefined) {
511
518
  throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
512
519
  }
@@ -1729,17 +1736,33 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
1729
1736
  `navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
1730
1737
  `with cancelTarget: false (or omitted).`);
1731
1738
  }
1732
- const visibleWhen = panel.visibleWhen;
1739
+ validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
1740
+ }
1741
+ function validateDashboardPanelVisibleWhen(context, visibleWhen) {
1733
1742
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
1734
1743
  throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
1735
1744
  }
1736
1745
  }
1746
+ function validateDashboardPanelSpan(featureName, screenId, panel) {
1747
+ if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
1748
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
1749
+ }
1750
+ }
1737
1751
  function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
1752
+ const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
1738
1753
  if (panel.stats.length === 0) {
1739
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" has an empty stats list.`);
1754
+ throw new Error(`${context} has an empty stats list.`);
1740
1755
  }
1756
+ if (panel.subtitle !== undefined && panel.label === undefined) {
1757
+ throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header.`);
1758
+ }
1759
+ validateDashboardPanelSpan(featureName, screenId, panel);
1760
+ validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
1741
1761
  for (const stat of panel.stats) {
1742
1762
  addPanelId(stat.id, "stat-group child");
1763
+ if (stat.visibleWhen !== undefined) {
1764
+ throw new Error(`${context} child "${stat.id}" sets visibleWhen — gate the whole group instead; a hidden cell would leave a gap in the strip.`);
1765
+ }
1743
1766
  if (!stat.query || typeof stat.query !== "string") {
1744
1767
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query.`);
1745
1768
  }
@@ -1758,6 +1781,67 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1758
1781
  if (panel.scrollable === true && panel.chart !== "stacked-area") {
1759
1782
  throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1760
1783
  }
1784
+ validateStackedAreaOnlyProps(where, panel);
1785
+ if (panel.legendTotals !== undefined && typeof panel.legendTotals !== "boolean") {
1786
+ throw new Error(`${where} has a non-boolean legendTotals.`);
1787
+ }
1788
+ for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
1789
+ if (typeof color !== "string" || color.trim() === "") {
1790
+ throw new Error(`${where} seriesColors["${key}"] must be a non-empty CSS color.`);
1791
+ }
1792
+ }
1793
+ for (const [kind, look] of Object.entries(panel.markerKinds ?? {})) {
1794
+ validateDashboardChartMarkerKind(`${where} markerKinds["${kind}"]`, look);
1795
+ }
1796
+ if (panel.ranges !== undefined)
1797
+ validateDashboardChartRanges(`${where} ranges`, panel.ranges);
1798
+ }
1799
+ function validateStackedAreaOnlyProps(where, panel) {
1800
+ // skip: stacked-area is the one chart kind that supports these props
1801
+ if (panel.chart === "stacked-area")
1802
+ return;
1803
+ const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
1804
+ if (stackedAreaOnly.length > 0) {
1805
+ throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
1806
+ }
1807
+ }
1808
+ const DASHBOARD_CHART_TONES = new Set([
1809
+ "positive",
1810
+ "negative",
1811
+ "active",
1812
+ "neutral",
1813
+ ]);
1814
+ function validateDashboardChartMarkerKind(where, look) {
1815
+ if (look.tone === undefined && look.color === undefined) {
1816
+ throw new Error(`${where} needs a tone or a color.`);
1817
+ }
1818
+ if (look.tone !== undefined && !DASHBOARD_CHART_TONES.has(look.tone)) {
1819
+ throw new Error(`${where}.tone "${String(look.tone)}" is not one of ${[...DASHBOARD_CHART_TONES].join(", ")}.`);
1820
+ }
1821
+ if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
1822
+ throw new Error(`${where}.color must be a non-empty CSS color.`);
1823
+ }
1824
+ }
1825
+ function validateDashboardChartRanges(where, ranges) {
1826
+ if (ranges.options.length === 0) {
1827
+ throw new Error(`${where}.options is empty — declare at least one option.`);
1828
+ }
1829
+ const values = new Set();
1830
+ for (const option of ranges.options) {
1831
+ if (option.value.length === 0 || option.label.length === 0) {
1832
+ throw new Error(`${where} option needs a non-empty value and label.`);
1833
+ }
1834
+ if (values.has(option.value)) {
1835
+ throw new Error(`${where}.options has duplicate value "${option.value}".`);
1836
+ }
1837
+ if (option.months !== undefined && (!Number.isInteger(option.months) || option.months <= 0)) {
1838
+ throw new Error(`${where} option "${option.value}" months "${String(option.months)}" must be a positive integer.`);
1839
+ }
1840
+ values.add(option.value);
1841
+ }
1842
+ if (!values.has(ranges.default)) {
1843
+ throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}).`);
1844
+ }
1761
1845
  }
1762
1846
  const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
1763
1847
  const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
@@ -1785,18 +1869,14 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
1785
1869
  if (!panel.query || typeof panel.query !== "string") {
1786
1870
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1787
1871
  }
1788
- if ("span" in panel &&
1789
- panel.span !== undefined &&
1790
- panel.span !== "half" &&
1791
- panel.span !== "full") {
1792
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
1793
- }
1872
+ validateDashboardPanelSpan(featureName, screenId, panel);
1794
1873
  if (panel.kind === "stat") {
1795
1874
  if (panel.valueField.length === 0) {
1796
1875
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
1797
1876
  }
1798
1877
  validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
1799
1878
  }
1879
+ validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
1800
1880
  if (panel.kind === "chart")
1801
1881
  validateDashboardChartDisplay(featureName, screenId, panel);
1802
1882
  if (panel.kind === "list") {
@@ -428,6 +428,9 @@ function projectField(fieldDef) {
428
428
  // fw#2780: author-supplied picker source — without it the client falls back to the list handler.
429
429
  if (typeof def["optionsQuery"] === "string")
430
430
  out["optionsQuery"] = def["optionsQuery"];
431
+ // Select: tier/tenant availability of the static options, loaded by the renderer.
432
+ if (typeof def["optionsAvailabilityQuery"] === "string")
433
+ out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
431
434
  if (typeof def["multiple"] === "boolean")
432
435
  out["multiple"] = def["multiple"];
433
436
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
@@ -1,7 +1,7 @@
1
1
  // ISO-8601 duration arithmetic — shared by wait and waitForEvent steps.
2
2
  // Accepts "P1D", "PT1H", "P1Y2M3DT4H5M6S" etc.
3
3
  // Uses approximate calendar math (365d/year, 30d/month). See #23.
4
- import { Temporal } from "temporal-polyfill";
4
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  export function addDuration(baseIso, duration) {
6
6
  const pattern = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
7
7
  const match = duration.match(pattern);
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
17
17
  export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
18
18
  export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionRowIdOf, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
19
19
  export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
20
- export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
20
+ export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
21
21
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
22
22
  export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
23
23
  export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
@@ -6,7 +6,7 @@
6
6
  // SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
7
7
  // config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
8
8
  // → audit-fähig.
9
- import { selectAllTenants, selectMembershipsOfUser, selectUserByEmail, } from "../db/queries/seed-context.js";
9
+ import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
10
10
  import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
11
11
  import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
12
12
  /** Builder: gibt eine factory-function zurück die der Runner pro-Migration
@@ -65,6 +65,20 @@ export function createSeedMigrationContext(args) {
65
65
  const rows = await selectAllTenants(args.dbRunner);
66
66
  return rows.map((r) => ({ id: r.id, name: r.name, tenantKey: r.tenant_key }));
67
67
  },
68
+ findTemplateResources: async (filter) => {
69
+ const rows = await selectTemplateResources(args.dbRunner, {
70
+ ...filter,
71
+ tenantId: filter?.tenantId ?? SYSTEM_TENANT_ID,
72
+ });
73
+ return rows.map((r) => ({
74
+ id: r.id,
75
+ tenantId: r.tenant_id,
76
+ slug: r.slug,
77
+ kind: r.kind,
78
+ locale: r.locale,
79
+ status: r.status,
80
+ }));
81
+ },
68
82
  db: args.dbRunner,
69
83
  };
70
84
  }
@@ -1,4 +1,4 @@
1
1
  export { type CreateSeedMigrationContextArgs, createSeedMigrationContext, } from "./context.js";
2
2
  export { createEsOperationsTable, type EsOperationAppliedBy, type EsOperationType, esOperationsTable, } from "./operations-schema.js";
3
3
  export { type RunPendingSeedMigrationsArgs, type RunPendingSeedMigrationsResult, runPendingSeedMigrations, } from "./runner.js";
4
- export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTenantRow, SeedUserRow, } from "./types.js";
4
+ export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTemplateResourceFilter, SeedTemplateResourceRow, SeedTenantRow, SeedUserRow, } from "./types.js";
@@ -51,6 +51,23 @@ export type SeedTenantRow = {
51
51
  readonly name: string;
52
52
  readonly tenantKey: string;
53
53
  };
54
+ /** Read-shape of a template-resolver row as exposed to seeds. */
55
+ export type SeedTemplateResourceRow = {
56
+ readonly id: string;
57
+ readonly tenantId: string;
58
+ readonly slug: string;
59
+ readonly kind: string;
60
+ readonly locale: string;
61
+ readonly status: string;
62
+ };
63
+ export type SeedTemplateResourceFilter = {
64
+ /** Defaults to the system tenant, where seeded system templates live. */
65
+ readonly tenantId?: TenantId;
66
+ readonly slug?: string;
67
+ readonly kind?: string;
68
+ readonly status?: string;
69
+ readonly locale?: string;
70
+ };
54
71
  export type SeedMigrationContext = {
55
72
  /** Event-Store-konformer Write via existing write-handler. System-User
56
73
  * als Executor bypassed Access-Check (Standard-Seed-Pattern). Events
@@ -84,6 +101,9 @@ export type SeedMigrationContext = {
84
101
  readonly findUserByEmail: (email: string) => Promise<SeedUserRow | null>;
85
102
  readonly findMembershipsOfUser: (userId: string) => Promise<readonly SeedMembershipRow[]>;
86
103
  readonly findTenants: () => Promise<readonly SeedTenantRow[]>;
104
+ /** Template rows ordered by slug, locale. Empty list when the
105
+ * template-resolver feature (its table) is not present. */
106
+ readonly findTemplateResources: (filter?: SeedTemplateResourceFilter) => Promise<readonly SeedTemplateResourceRow[]>;
87
107
  /** Escape-Hatch — direkter DB-Zugang. Nur für READ-only Lookups die der
88
108
  * Context nicht standard-mäßig anbietet. WRITES via systemWriteAs!
89
109
  * Type ist DbRunner (Connection oder aktive Tx) weil der Runner den
@@ -1,5 +1,6 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { type EventMetadata, type StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
3
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
4
  import type { DbRunner } from "../db/index.js";
4
5
  import type { TenantId } from "../engine/types/index.js";
5
6
  export type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
@@ -1,12 +1,6 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
3
- // Value-only import, aliased to avoid shadowing the ambient global
4
- // `Temporal` TYPE this file's other Temporal.Instant annotations resolve
5
- // against (StoredEvent.createdAt et al. — importing the bare name here
6
- // conflicts with that ambient type, see #1438). Bun doesn't expose Temporal
7
- // as a runtime value on globalThis, so the un-aliased call below crashed
8
- // with "Temporal is not defined" outside boot paths that install it (#1480).
9
- import { Temporal as TemporalPolyfill } from "temporal-polyfill";
3
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
10
4
  import { requestContext } from "../api/request-context.js";
11
5
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
12
6
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
@@ -150,18 +144,11 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
150
144
  if (!row)
151
145
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
152
146
  const createdAt = row.created_at instanceof Date
153
- ? TemporalPolyfill.Instant.fromEpochMilliseconds(row.created_at.getTime())
154
- : TemporalPolyfill.Instant.from(row.created_at);
147
+ ? Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime())
148
+ : Temporal.Instant.from(row.created_at);
155
149
  return {
156
150
  id: typeof row.id === "bigint" ? row.id : BigInt(row.id),
157
- // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
158
- // at runtime, two separate .d.ts sources (temporal-polyfill's bundled
159
- // types vs the ambient temporal-spec global) disagree on a couple of
160
- // method-overload signatures (until/equals argument types), so TS treats
161
- // them as nominally distinct. Safe: this value is only ever consumed as
162
- // a Temporal.Instant (comparisons, .toString(), arithmetic), never
163
- // narrowed on the specific overload shape that differs.
164
- createdAt: createdAt,
151
+ createdAt,
165
152
  };
166
153
  }
167
154
  function buildStoredEvent(event, newVersion, eventVersion, row) {
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadL
9
9
  export { toStoredEvent } from "./row-to-stored-event.js";
10
10
  export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
11
11
  export { createSnapshotsTable, type LoadAggregateWithSnapshotOptions, type LoadAggregateWithSnapshotResult, loadAggregateWithSnapshot, loadLatestSnapshot, type SaveSnapshotArgs, type Snapshot, type SnapshotReducer, saveSnapshot, snapshotsTable, } from "./snapshot.js";
12
+ export { type AppendEventInTenantDbOptions, appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
12
13
  export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, type TransferAggregateStreamsArgs, transferAggregateStreams, } from "./transfer.js";
13
14
  export { type EventUpcasters, makeUpcastCtx, type UpcasterErrorPolicy, type UpcastOptions, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
14
15
  export { createUpcasterDeadLetterTable, type DeadLetterRow, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetter
9
9
  export { toStoredEvent } from "./row-to-stored-event.js";
10
10
  export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
11
11
  export { createSnapshotsTable, loadAggregateWithSnapshot, loadLatestSnapshot, saveSnapshot, snapshotsTable, } from "./snapshot.js";
12
+ export { appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
12
13
  export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, transferAggregateStreams, } from "./transfer.js";
13
14
  export { makeUpcastCtx, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
14
15
  export { createUpcasterDeadLetterTable, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
@@ -0,0 +1,8 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import type { Registry } from "../engine/types/index.js";
3
+ import { type EventToAppend, type StoredEvent } from "./event-store.js";
4
+ export type AppendEventInTenantDbOptions = {
5
+ readonly registry?: Registry;
6
+ };
7
+ export declare function appendEventInTenantDb(tenantDb: TenantDb, event: Omit<EventToAppend, "tenantId">, options?: AppendEventInTenantDbOptions): Promise<StoredEvent>;
8
+ export declare function getStreamVersionInTenantDb(tenantDb: TenantDb, aggregateId: string): Promise<number>;
@@ -0,0 +1,16 @@
1
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
2
+ import { runProjectionsForEvent } from "../pipeline/projections-runner.js";
3
+ import { append, getStreamVersion } from "./event-store.js";
4
+ // Appends on the connection or transaction the TenantDb is bound to. The tenant is always the
5
+ // TenantDb's own, so a caller cannot write into another tenant's stream; a TenantDb the framework
6
+ // did not build (or a fail-closed systemScope guard) is rejected by tenantDbRunner.
7
+ export async function appendEventInTenantDb(tenantDb, event, options = {}) {
8
+ const runner = tenantDbRunner(tenantDb);
9
+ const stored = await append(runner, { ...event, tenantId: tenantDb.tenantId });
10
+ if (options.registry)
11
+ await runProjectionsForEvent(stored, options.registry, runner);
12
+ return stored;
13
+ }
14
+ export async function getStreamVersionInTenantDb(tenantDb, aggregateId) {
15
+ return getStreamVersion(tenantDbRunner(tenantDb), aggregateId, tenantDb.tenantId);
16
+ }
@@ -90,8 +90,10 @@ function pushDashboardPanelKeys(out, panel) {
90
90
  if (panel.kind === "custom")
91
91
  return;
92
92
  pushKey(out, panel.label);
93
- if (panel.kind === "chart")
93
+ if (panel.kind === "chart" || panel.kind === "stat-group")
94
94
  pushKey(out, panel.subtitle);
95
+ if (panel.kind === "chart")
96
+ pushDashboardChartRangeKeys(out, panel);
95
97
  if (panel.kind === "chart" ||
96
98
  panel.kind === "list" ||
97
99
  panel.kind === "feed" ||
@@ -111,6 +113,10 @@ function pushDashboardPanelKeys(out, panel) {
111
113
  }
112
114
  }
113
115
  }
116
+ function pushDashboardChartRangeKeys(out, panel) {
117
+ for (const option of panel.ranges?.options ?? [])
118
+ pushKey(out, option.label);
119
+ }
114
120
  function pushDashboardFilterKeys(out, filter) {
115
121
  pushKey(out, filter.label);
116
122
  if (filter.allLabel !== undefined)