@cosmicdrift/kumiko-framework 0.329.0 → 0.330.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bun-db/connection.d.ts +1 -0
  2. package/dist/bun-db/connection.js +1 -0
  3. package/dist/bun-db/index.d.ts +1 -1
  4. package/dist/bun-db/index.js +1 -1
  5. package/dist/bun-db/query.d.ts +3 -0
  6. package/dist/bun-db/query.js +99 -0
  7. package/dist/db/index.d.ts +1 -1
  8. package/dist/db/index.js +1 -1
  9. package/dist/db/query-api.d.ts +1 -1
  10. package/dist/db/query-api.js +1 -1
  11. package/dist/db/query.d.ts +1 -1
  12. package/dist/db/query.js +1 -1
  13. package/dist/db/table-builder.d.ts +1 -0
  14. package/dist/db/tenant-db.js +5 -1
  15. package/dist/engine/boot-validator/pii-retention.js +0 -10
  16. package/dist/engine/boot-validator/query-output-columns.js +4 -0
  17. package/dist/engine/boot-validator/screens.js +48 -0
  18. package/dist/engine/build-app-schema.js +3 -0
  19. package/dist/engine/project-app-schema-for-roles.js +11 -1
  20. package/dist/engine/screen-helpers.js +4 -0
  21. package/dist/event-store/admin-api.d.ts +1 -0
  22. package/dist/event-store/admin-api.js +1 -0
  23. package/dist/event-store/event-store.d.ts +1 -0
  24. package/dist/event-store/event-store.js +1 -0
  25. package/dist/event-store/row-to-stored-event.d.ts +1 -0
  26. package/dist/event-store/snapshot.d.ts +1 -0
  27. package/dist/event-store/snapshot.js +1 -0
  28. package/dist/i18n/required-surface-keys.js +13 -2
  29. package/dist/jobs/job-runner.d.ts +1 -0
  30. package/dist/pipeline/event-dispatcher-admin.d.ts +1 -0
  31. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -0
  32. package/dist/pipeline/event-dispatcher-delivery.js +1 -0
  33. package/dist/pipeline/event-retention.d.ts +1 -0
  34. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  35. package/dist/time/polyfill.d.ts +1 -0
  36. package/dist/time/polyfill.js +1 -0
  37. package/dist/ui-types/index.d.ts +1 -1
  38. package/package.json +4 -4
  39. package/src/changes.json +13 -0
@@ -1,3 +1,4 @@
1
+ /// <reference types="bun-types" preserve="true" />
1
2
  import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
2
3
  export type { PgListenClient };
3
4
  export type BunDbConnection = InstanceType<typeof Bun.SQL>;
@@ -1,3 +1,4 @@
1
+ /// <reference types="bun-types" preserve="true" />
1
2
  // Bun.SQL-basierter Connection-Layer (KEIN drizzle). Production-Pfad
2
3
  // nach drizzle-removal. Erstellt parallel zum legacy `db/connection.ts`
3
4
  // — apps migrieren schritt-für-schritt, alte Datei weg sobald 0 Konsumenten.
@@ -1,4 +1,4 @@
1
1
  export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
2
2
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
3
3
  export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query.js";
4
- export { asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,4 @@
1
1
  // bun-db: Bun.sql-basierte DB-API ohne drizzle.
2
2
  // Production-Pfad nach drizzle-removal.
3
3
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
4
- export { asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,3 +1,4 @@
1
+ import type { AggregateRow, AggregateSpec } from "@cosmicdrift/kumiko-types/aggregate-types";
1
2
  import type { SelectOptions, WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
2
3
  import type { EntityTableMeta } from "../db/entity-table-meta.js";
3
4
  import { type NotExecutorOnly } from "../db/table-builder.js";
@@ -51,6 +52,8 @@ export type DeleteManyBatchedResult = {
51
52
  readonly batches: number;
52
53
  };
53
54
  export declare function countWhere(db: AnyDb, table: TableLike, where?: WhereObject): Promise<number>;
55
+ export declare function isTimestamptzType(pgType: string | undefined): boolean;
56
+ export declare function aggregateWhere(db: AnyDb, table: TableLike, spec: AggregateSpec, where?: WhereObject): Promise<readonly AggregateRow[]>;
54
57
  export declare function upsertOnConflict<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, options: UpsertOnConflictOptions): Promise<TRow | undefined>;
55
58
  export declare function upsertByPk<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>, updateOnConflict?: Record<string, unknown>): Promise<TRow | undefined>;
56
59
  export type IncrementCounterOptions = {
@@ -989,6 +989,105 @@ export async function countWhere(db, table, where = {}) {
989
989
  const rows = (await unsafeReadRetrying(db, sqlText, values));
990
990
  return rows[0]?.count ?? 0;
991
991
  }
992
+ const AGGREGATE_BUCKET_UNITS = ["hour", "day"];
993
+ const AGGREGATE_ORDER_DIRECTIONS = ["asc", "desc"];
994
+ function requireAggregateColumn(info, field, role) {
995
+ if (!info.hasColumn(field)) {
996
+ throw new Error(`aggregateWhere: ${role} "${field}" is not a column of ${info.name}`);
997
+ }
998
+ return quoteIdent(info.columnOf(field));
999
+ }
1000
+ export function isTimestamptzType(pgType) {
1001
+ return pgType === "timestamptz" || pgType === "timestamptz(3)";
1002
+ }
1003
+ function aggregateKeyOf(raw) {
1004
+ if (raw === null || raw === undefined)
1005
+ return null;
1006
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean")
1007
+ return raw;
1008
+ if (typeof raw === "bigint")
1009
+ return Number(raw);
1010
+ if (raw instanceof Date)
1011
+ return raw.toISOString();
1012
+ return String(raw);
1013
+ }
1014
+ function aggregateMeasureSql(info, measure) {
1015
+ switch (measure.fn) {
1016
+ case "count":
1017
+ return "COUNT(*)";
1018
+ case "countDistinct":
1019
+ return `COUNT(DISTINCT ${requireAggregateColumn(info, measure.field, "measure field")})`;
1020
+ case "sum":
1021
+ return `COALESCE(SUM(${requireAggregateColumn(info, measure.field, "measure field")}), 0)`;
1022
+ case "avg":
1023
+ return `AVG(${requireAggregateColumn(info, measure.field, "measure field")})`;
1024
+ default:
1025
+ throw new Error(`aggregateWhere: unknown measure fn "${String(measure.fn)}"`);
1026
+ }
1027
+ }
1028
+ function aggregateDimensionSql(info, dimension, index, params) {
1029
+ const column = requireAggregateColumn(info, dimension.field, "groupBy field");
1030
+ if (!("bucket" in dimension))
1031
+ return `${column} AS k${index}`;
1032
+ if (!AGGREGATE_BUCKET_UNITS.includes(dimension.bucket)) {
1033
+ throw new Error(`aggregateWhere: unknown bucket "${String(dimension.bucket)}"`);
1034
+ }
1035
+ if (!isTimestamptzType(info.pgTypeOf(info.columnOf(dimension.field)))) {
1036
+ throw new Error(`aggregateWhere: bucket on "${dimension.field}" requires a timestamptz column`);
1037
+ }
1038
+ params.push(dimension.timeZone);
1039
+ return `(EXTRACT(EPOCH FROM date_trunc('${dimension.bucket}', ${column}, $${params.length}::text)) * 1000)::float8 AS k${index}`;
1040
+ }
1041
+ function assertValidAggregateLimits(spec) {
1042
+ if (spec.limit !== undefined && (!Number.isInteger(spec.limit) || spec.limit < 0)) {
1043
+ throw new Error(`aggregateWhere: limit must be a non-negative integer, got ${spec.limit}`);
1044
+ }
1045
+ if (spec.orderByValue !== undefined && !AGGREGATE_ORDER_DIRECTIONS.includes(spec.orderByValue)) {
1046
+ throw new Error(`aggregateWhere: unknown orderByValue "${String(spec.orderByValue)}"`);
1047
+ }
1048
+ }
1049
+ function aggregateGroupOrderLimitSql(spec, dimensionCount) {
1050
+ let sqlText = "";
1051
+ if (dimensionCount > 0) {
1052
+ const positions = Array.from({ length: dimensionCount }, (_, index) => String(index + 1));
1053
+ sqlText += ` GROUP BY ${positions.join(", ")}`;
1054
+ const orderParts = spec.orderByValue === undefined
1055
+ ? positions
1056
+ : [`${dimensionCount + 1} ${spec.orderByValue === "desc" ? "DESC" : "ASC"}`, ...positions];
1057
+ sqlText += ` ORDER BY ${orderParts.join(", ")}`;
1058
+ }
1059
+ if (spec.limit !== undefined)
1060
+ sqlText += ` LIMIT ${spec.limit}`;
1061
+ return sqlText;
1062
+ }
1063
+ export async function aggregateWhere(db, table, spec, where = {}) {
1064
+ const scoped = tenantDbDelegate(db);
1065
+ if (scoped) {
1066
+ return scoped.aggregate(table, spec, where);
1067
+ }
1068
+ const info = extractTableInfo(table);
1069
+ const params = [];
1070
+ const measureSql = aggregateMeasureSql(info, spec.measure);
1071
+ const dimensions = spec.groupBy ?? [];
1072
+ const dimensionSql = dimensions.map((dimension, index) => aggregateDimensionSql(info, dimension, index, params));
1073
+ assertValidAggregateLimits(spec);
1074
+ const selectList = [...dimensionSql, `(${measureSql})::float8 AS value`].join(", ");
1075
+ let sqlText = `SELECT ${selectList} FROM ${quoteIdent(info.name)}`;
1076
+ if (Object.keys(where).length > 0) {
1077
+ const w = buildWhereClause(info, where, params.length + 1);
1078
+ sqlText += ` WHERE ${w.sqlText}`;
1079
+ params.push(...w.values);
1080
+ }
1081
+ sqlText += aggregateGroupOrderLimitSql(spec, dimensions.length);
1082
+ const raw = (await unsafeReadRetrying(db, sqlText, params));
1083
+ return raw.map((row) => {
1084
+ const value = row["value"];
1085
+ return {
1086
+ keys: dimensions.map((_, index) => aggregateKeyOf(row[`k${index}`])),
1087
+ value: value === null || value === undefined ? null : Number(value),
1088
+ };
1089
+ });
1090
+ }
992
1091
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
993
1092
  export async function upsertOnConflict(db, table, values, options) {
994
1093
  assertNotTenantScoped(db, "upsertOnConflict");
@@ -26,7 +26,7 @@ export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation,
26
26
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
27
27
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
28
28
  export type { SelectOptions, WhereObject, WhereValue } from "./query-api.js";
29
- export { asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
29
+ export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
30
30
  export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
31
31
  export { seedReferenceData } from "./reference-data.js";
32
32
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
package/dist/db/index.js CHANGED
@@ -19,7 +19,7 @@ export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.
19
19
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
20
20
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
21
21
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
22
- export { asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
22
+ export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
23
23
  export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
24
24
  export { seedReferenceData } from "./reference-data.js";
25
25
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
@@ -1,2 +1,2 @@
1
1
  export type { SelectOptions, WhereObject, WhereOperator, WhereValue, } from "../db/query.js";
2
- export { asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "../db/query.js";
2
+ export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "../db/query.js";
@@ -3,4 +3,4 @@
3
3
  // damit existing imports `@cosmicdrift/kumiko-framework/db` weiterhin
4
4
  // funktionieren während wir die Callers schrittweise auf den direkten
5
5
  // bun-db-Import migrieren.
6
- export { asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "../db/query.js";
6
+ export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "../db/query.js";
@@ -1 +1 @@
1
- export { type AnyDb, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
1
+ export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
package/dist/db/query.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Provider-neutrale Query-API. Re-exported aus bun-db/query.
2
2
  // Alle Consumer importieren von hier, nicht direkt aus bun-db/.
3
- export { asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
3
+ export { aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
2
3
  import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
3
4
  import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } from "../engine/types/index.js";
@@ -1,6 +1,6 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
- import { asEntityTableMeta, asRawClient, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
3
+ import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
4
4
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
5
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
6
6
  import { fallbackEscapeHatchReporter } from "../observability/escape-hatch-report.js";
@@ -443,6 +443,10 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
443
443
  const filter = readWhere(table, where);
444
444
  return withDbSpan("select", table, async () => bunCountWhere(db, table, filter));
445
445
  },
446
+ aggregate(table, spec, where) {
447
+ const filter = readWhere(table, where);
448
+ return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
449
+ },
446
450
  insertOne(table, values) {
447
451
  if (mode === "tenant" &&
448
452
  hasTenantColumn(table) &&
@@ -188,16 +188,6 @@ export function validatePiiAndRetention(feature) {
188
188
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
189
189
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
190
190
  }
191
- else if (!entityHasAnonymizableSubjectField) {
192
- const hasSubjectRef = Object.values(fieldsByName).some((f) => {
193
- const a = f; // @cast-boundary schema-walk
194
- return Boolean(a.subjectRef);
195
- });
196
- if (hasSubjectRef) {
197
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
198
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`);
199
- }
200
- }
201
191
  }
202
192
  }
203
193
  }
@@ -67,6 +67,10 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
67
67
  if (screen.header.status !== undefined)
68
68
  checkHeaderField("status", screen.header.status);
69
69
  }
70
+ if (screen.recordTitleField !== undefined) {
71
+ const field = screen.recordTitleField;
72
+ checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
73
+ }
70
74
  for (const metric of screen.metrics ?? []) {
71
75
  const field = metricField(metric);
72
76
  checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
@@ -780,6 +780,10 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
780
780
  `and detailFor — the auto-generated "Edit" action navigates using the path's row id, which a ` +
781
781
  `singleton screen never has. Remove detailFor, or declare an explicit "edit" action instead.`);
782
782
  }
783
+ if (screen.recordTitleField !== undefined && screen.header !== undefined) {
784
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets both header and ` +
785
+ `recordTitleField — header.title already names the record, so drop recordTitleField.`);
786
+ }
783
787
  if (screen.layout.sections.length === 0) {
784
788
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has an empty sections list — ` +
785
789
  `declare at least one section.`);
@@ -1210,6 +1214,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1210
1214
  `is not sortable. Set sortable: true on the field definition or pick another field.`);
1211
1215
  }
1212
1216
  }
1217
+ if (screen.createScreen !== undefined &&
1218
+ !Object.hasOwn(feature.screens, screen.createScreen)) {
1219
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) createScreen ` +
1220
+ `"${screen.createScreen}" does not resolve to a registered screen in this feature. ` +
1221
+ `Known screens in this feature: ${Object.keys(feature.screens).sort().join(", ") || "(none)"}.`);
1222
+ }
1213
1223
  // Screen-Filter (Tier 2.7c) — drei Layer Author-Code-Check:
1214
1224
  // 1) Field existiert auf der Entity (Tippfehler = leere Liste
1215
1225
  // statt Crash; Boot-Fail ist deutlich besser).
@@ -1402,6 +1412,11 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1402
1412
  }
1403
1413
  }
1404
1414
  }
1415
+ if (screen.recordTitleField !== undefined && !fieldNames.has(screen.recordTitleField)) {
1416
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) recordTitleField ` +
1417
+ `"${screen.recordTitleField}" is not a field of entity "${screen.entity}" ` +
1418
+ `(known: ${[...fieldNames].sort().join(", ")}).`);
1419
+ }
1405
1420
  if (screen.redirect !== undefined) {
1406
1421
  validateRedirectTarget(feature, screenId, "entityEdit", screen.redirect, allScreenQns);
1407
1422
  }
@@ -1441,6 +1456,33 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1441
1456
  if (screen.filter !== undefined) {
1442
1457
  validateDashboardFilterDefinition(featureName, screenId, screen.filter);
1443
1458
  }
1459
+ if (screen.timeRange !== undefined) {
1460
+ validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
1461
+ }
1462
+ }
1463
+ function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
1464
+ const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
1465
+ if (timeRange.id.length === 0)
1466
+ throw new Error(`${prefix} has an empty id.`);
1467
+ if (timeRange.id === filterId) {
1468
+ throw new Error(`${prefix} id "${timeRange.id}" collides with the filter id — both live in the URL search params.`);
1469
+ }
1470
+ if (timeRange.options.length === 0) {
1471
+ throw new Error(`${prefix}.options is empty — declare at least one option.`);
1472
+ }
1473
+ const values = new Set();
1474
+ for (const option of timeRange.options) {
1475
+ if (option.value.length === 0 || option.label.length === 0) {
1476
+ throw new Error(`${prefix} option needs a non-empty value and label.`);
1477
+ }
1478
+ if (values.has(option.value)) {
1479
+ throw new Error(`${prefix}.options has duplicate value "${option.value}".`);
1480
+ }
1481
+ values.add(option.value);
1482
+ }
1483
+ if (!values.has(timeRange.default)) {
1484
+ throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}).`);
1485
+ }
1444
1486
  }
1445
1487
  const EMBEDDABLE_SCREEN_TYPES = new Set([
1446
1488
  "projectionList",
@@ -1491,6 +1533,12 @@ function validateDashboardQueryPanel(featureName, screenId, panel) {
1491
1533
  if (!panel.query || typeof panel.query !== "string") {
1492
1534
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
1493
1535
  }
1536
+ if ("span" in panel &&
1537
+ panel.span !== undefined &&
1538
+ panel.span !== "half" &&
1539
+ panel.span !== "full") {
1540
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
1541
+ }
1494
1542
  if (panel.kind === "stat" && panel.valueField.length === 0) {
1495
1543
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
1496
1544
  }
@@ -426,6 +426,9 @@ function projectField(fieldDef) {
426
426
  const unit = def["unit"];
427
427
  if (typeof unit === "string" || (isPlainObject(unit) && typeof unit["field"] === "string"))
428
428
  out["unit"] = unit;
429
+ // number/decimal/bigInt: `false` drops thousands separators (e.g. a year).
430
+ if (typeof def["grouping"] === "boolean")
431
+ out["grouping"] = def["grouping"];
429
432
  // text: "password" masks the input and blocks URL prefill in the renderer.
430
433
  if (typeof def["format"] === "string")
431
434
  out["format"] = def["format"];
@@ -54,6 +54,7 @@
54
54
  // buildAppSchema
55
55
  // - WorkspaceSchema.definition.nav — the raw explicit nav-QN list,
56
56
  // verbatim in JSON, distinct from the computed/filtered navMembers
57
+ // - entityList.createScreen (same-feature target, dropped when not kept)
57
58
  // - listScreenId (projectionDetail/entityEdit/actionForm/secretMint/custom —
58
59
  // the breadcrumb "back to list" target). It's serialized, so a visible
59
60
  // screen would otherwise disclose a denied screen's short id. Resolved
@@ -419,7 +420,16 @@ function projectCancelTarget(featureName, cancelTarget, keptScreenQns) {
419
420
  return cancelTarget;
420
421
  return isScreenTargetKept(featureName, cancelTarget, keptScreenQns) ? cancelTarget : undefined;
421
422
  }
422
- function projectListScreen(screen, feature, indices, keptScreenQns) {
423
+ function dropUnreachableCreateScreen(screen, feature, keptScreenQns) {
424
+ if (screen.type !== "entityList" || screen.createScreen === undefined)
425
+ return screen;
426
+ if (isScreenTargetKept(feature.featureName, screen.createScreen, keptScreenQns))
427
+ return screen;
428
+ const { createScreen: _unreachable, ...rest } = screen;
429
+ return rest;
430
+ }
431
+ function projectListScreen(original, feature, indices, keptScreenQns) {
432
+ const screen = dropUnreachableCreateScreen(original, feature, keptScreenQns);
423
433
  const { rowActions, toolbarActions, ...rest } = screen;
424
434
  const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
425
435
  const nextToolbarActions = projectToolbarActionArray(toolbarActions, feature, indices, keptScreenQns);
@@ -23,7 +23,11 @@ export function resolveNavParentScreen(screens, detail, getId) {
23
23
  // rowActions, toolbarActions, and drawer-kind actions (which mount inline
24
24
  // and have no page of their own) all count as "reached from" this list.
25
25
  const navigatesToDetail = (actions) => (actions ?? []).some((a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId);
26
+ // The create button of an entityList navigates to its createScreen, so the
27
+ // list is that screen's parent just like for a rowAction target.
26
28
  const listFromRowAction = screens.find((s) => {
29
+ if (s.type === "entityList" && s.createScreen === detailScreenId)
30
+ return true;
27
31
  if (s.type !== "entityList" && s.type !== "projectionList")
28
32
  return false;
29
33
  return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { DbRunner } from "../db/index.js";
2
3
  import type { TenantId } from "../engine/types/index.js";
3
4
  import type { EventMetadata } from "./event-store.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Event-Store Admin-API — Marten-Bypass für Legacy-Daten-Importe.
2
3
  // Spec + Verhalten: docs/plans/features/event-store-admin-api.md
3
4
  //
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { type EventMetadata, type StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
2
3
  import type { DbRunner } from "../db/index.js";
3
4
  import type { TenantId } from "../engine/types/index.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
2
3
  // Value-only import, aliased to avoid shadowing the ambient global
3
4
  // `Temporal` TYPE this file's other Temporal.Instant annotations resolve
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { TenantId } from "../engine/types/index.js";
2
3
  import type { EventMetadata, StoredEvent } from "./event-store.js";
3
4
  type EventRow = {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { LoadAggregateWithSnapshotOptions, LoadAggregateWithSnapshotResult, SnapshotReducer } from "@cosmicdrift/kumiko-types/snapshot-types";
2
3
  import type { DbConnection, DbRunner } from "../db/connection.js";
3
4
  import type { TenantId } from "../engine/types/index.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // sql now comes from native dialect
2
3
  import { index, instant, integer, jsonb, table as pgTable, primaryKey, sql, text, uuid, } from "../db/dialect.js";
3
4
  import { ensureSnapshotVersionColumn, upsertSnapshot } from "../db/queries/event-store.js";
@@ -57,12 +57,25 @@ function pushDashboardScreenKeys(out, dashboard) {
57
57
  pushDashboardPanelKeys(out, panel);
58
58
  if (dashboard.filter !== undefined)
59
59
  pushDashboardFilterKeys(out, dashboard.filter);
60
+ for (const option of dashboard.timeRange?.options ?? [])
61
+ pushKey(out, option.label);
62
+ pushKey(out, dashboard.scope?.badge);
63
+ pushKey(out, dashboard.scope?.notice);
60
64
  }
61
65
  function pushDashboardPanelKeys(out, panel) {
62
66
  // skip: custom-Panel übersetzt sich selbst, kein Key hier
63
67
  if (panel.kind === "custom")
64
68
  return;
65
69
  pushKey(out, panel.label);
70
+ if (panel.kind === "chart")
71
+ pushKey(out, panel.subtitle);
72
+ if (panel.kind === "chart" ||
73
+ panel.kind === "list" ||
74
+ panel.kind === "feed" ||
75
+ panel.kind === "progress-list") {
76
+ pushKey(out, panel.emptyLabel);
77
+ pushKey(out, panel.emptyHint);
78
+ }
66
79
  if (panel.kind === "stat-group") {
67
80
  for (const stat of panel.stats)
68
81
  pushKey(out, stat.label);
@@ -74,8 +87,6 @@ function pushDashboardPanelKeys(out, panel) {
74
87
  pushKey(out, normalized.label);
75
88
  }
76
89
  }
77
- if (panel.kind === "feed" && panel.emptyLabel !== undefined)
78
- pushKey(out, panel.emptyLabel);
79
90
  }
80
91
  function pushDashboardFilterKeys(out, filter) {
81
92
  pushKey(out, filter.label);
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { type AppContext, type DispatchWriteRef, type JobRunIn, type JobTriggerEvent, type Registry, type SessionUser, type TenantId } from "../engine/types/index.js";
2
3
  /**
3
4
  * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { DbConnection } from "../db/connection.js";
2
3
  export type ConsumerRecoveryState = {
3
4
  readonly name: string;
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { DbConnection, DbRunner, DbTx } from "../db/connection.js";
2
3
  import { type PendingIdRange } from "../db/queries/event-store.js";
3
4
  import type { AppContext } from "../engine/types/index.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Value-only import, aliased to avoid shadowing the ambient global
2
3
  // `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
3
4
  // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { DbConnection } from "../db/connection.js";
2
3
  export type PruneEventsOptions = {
3
4
  readonly olderThan?: Temporal.Instant;
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import type { DbConnection } from "../db/connection.js";
2
3
  import type { Registry } from "../engine/types/index.js";
3
4
  import type { Meter } from "../observability/types/metric.js";
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  /**
2
3
  * Ensure `globalThis.Temporal` is available. Idempotent.
3
4
  */
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Temporal polyfill bootstrap.
2
3
  //
3
4
  // Temporal is native in Chromium 144+ / Firefox 139+, but missing in Safari,
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
- export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
12
+ export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
13
  export { metricField } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.329.0",
3
+ "version": "0.330.2",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.329.0",
210
- "@cosmicdrift/kumiko-types": "0.329.0",
209
+ "@cosmicdrift/kumiko-http": "0.330.2",
210
+ "@cosmicdrift/kumiko-types": "0.330.2",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.329.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.330.2",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.330.2",
4
+ "type": "fix",
5
+ "title": "createScreen role projection without a type assertion (no behaviour change)",
6
+ "detail": "Internal cleanup of the createScreen role projection added in the previous release; behaviour is unchanged."
7
+ },
8
+ {
9
+ "version": "0.330.0",
10
+ "type": "improvement",
11
+ "title": "TenantDb.aggregate and aggregateWhere: grouped count, countDistinct, sum and avg with time buckets (fw#3396)",
12
+ "detail": "`tenantDb.aggregate(table, { measure, groupBy?, orderByValue?, limit? }, where?)` and the standalone `aggregateWhere(db, table, spec, where?)` run one grouped aggregate with the same tenant scoping as `read`/`count`. A dimension can be a plain column or a time bucket (`{ field, bucket: 'hour' | 'day' | ..., timeZone }`) on a timestamptz column; bucket keys come back as epoch milliseconds. Types live in `@cosmicdrift/kumiko-types/aggregate-types`. Unknown fields, non-timestamptz bucket columns and bad limits throw before any SQL runs.",
13
+ "migration": "Only code that implements the `TenantDb` interface itself (for example a hand-written mock) must add an `aggregate` method; `createTenantDb` already provides it."
14
+ },
2
15
  {
3
16
  "version": "0.329.0",
4
17
  "type": "improvement",