@cosmicdrift/kumiko-bundled-features 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 (72) hide show
  1. package/README.md +17 -0
  2. package/dist/admin-shell/feature.d.ts +5 -0
  3. package/dist/admin-shell/feature.js +15 -61
  4. package/dist/admin-shell/i18n.js +35 -9
  5. package/dist/admin-shell/overview-allowlist.d.ts +2 -2
  6. package/dist/admin-shell/overview-allowlist.js +15 -9
  7. package/dist/admin-shell/overview-panels.d.ts +12 -0
  8. package/dist/admin-shell/overview-panels.js +167 -0
  9. package/dist/auth-email-password/email-templates.d.ts +1 -0
  10. package/dist/auth-email-password/email-templates.js +1 -1
  11. package/dist/auth-mfa/i18n.js +9 -0
  12. package/dist/auth-mfa/screens.js +3 -3
  13. package/dist/billing-foundation/checkout-core.d.ts +1 -0
  14. package/dist/billing-foundation/checkout-core.js +1 -0
  15. package/dist/billing-foundation/constants.d.ts +1 -0
  16. package/dist/billing-foundation/constants.js +1 -0
  17. package/dist/billing-foundation/get-subscription-for-tenant.d.ts +1 -0
  18. package/dist/billing-foundation/get-subscription-for-tenant.js +1 -0
  19. package/dist/billing-foundation/i18n.js +1 -1
  20. package/dist/billing-foundation/types.d.ts +1 -0
  21. package/dist/billing-foundation/types.js +1 -0
  22. package/dist/cap-counter/enforce-cap.d.ts +1 -0
  23. package/dist/cap-counter/enforce-cap.js +1 -0
  24. package/dist/cap-overview/handlers/caps-usage.query.js +3 -3
  25. package/dist/data-retention/run-retention-cleanup.js +6 -1
  26. package/dist/delivery/tables.js +4 -2
  27. package/dist/jobs/feature.js +2 -2
  28. package/dist/jobs/i18n.js +6 -0
  29. package/dist/jobs/job-run-table.js +1 -0
  30. package/dist/metrics/compute.d.ts +13 -0
  31. package/dist/metrics/compute.js +127 -0
  32. package/dist/metrics/constants.d.ts +7 -0
  33. package/dist/metrics/constants.js +14 -0
  34. package/dist/metrics/default-metrics.d.ts +9 -0
  35. package/dist/metrics/default-metrics.js +115 -0
  36. package/dist/metrics/feature.d.ts +4 -0
  37. package/dist/metrics/feature.js +119 -0
  38. package/dist/metrics/i18n.d.ts +5 -0
  39. package/dist/metrics/i18n.js +16 -0
  40. package/dist/metrics/index.d.ts +5 -0
  41. package/dist/metrics/index.js +6 -0
  42. package/dist/metrics/types.d.ts +70 -0
  43. package/dist/metrics/types.js +3 -0
  44. package/dist/metrics/validate.d.ts +3 -0
  45. package/dist/metrics/validate.js +74 -0
  46. package/dist/metrics/window.d.ts +13 -0
  47. package/dist/metrics/window.js +63 -0
  48. package/dist/personal-access-tokens/i18n.js +3 -0
  49. package/dist/personal-access-tokens/screens.js +6 -1
  50. package/dist/sessions/schema/user-session.js +1 -0
  51. package/dist/shared/grace-period.d.ts +1 -0
  52. package/dist/shared/grace-period.js +1 -0
  53. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +1 -0
  54. package/dist/tenant-lifecycle/run-tenant-destroy.js +1 -0
  55. package/dist/tier-engine/feature.js +1 -1
  56. package/dist/tier-engine/i18n.js +3 -0
  57. package/dist/user-data-rights/email-templates.d.ts +1 -0
  58. package/dist/user-data-rights/email-templates.js +1 -1
  59. package/dist/user-data-rights/feature.js +2 -0
  60. package/dist/user-data-rights/web/i18n.js +2 -0
  61. package/dist/user-profile/feature.js +4 -3
  62. package/dist/user-profile/i18n.js +2 -1
  63. package/package.json +17 -9
  64. package/src/admin-shell/changes.json +13 -0
  65. package/src/billing-foundation/changes.json +6 -0
  66. package/src/data-retention/changes.json +5 -0
  67. package/src/delivery/changes.json +7 -0
  68. package/src/jobs/changes.json +7 -0
  69. package/src/metrics/changes.json +15 -0
  70. package/src/personal-access-tokens/changes.json +5 -0
  71. package/src/sessions/changes.json +7 -0
  72. package/src/user-data-rights/changes.json +6 -0
@@ -132,7 +132,7 @@ export function createJobsFeature(options = {}) {
132
132
  layout: { sections: [{ fields: ["jobName", "payload"] }] },
133
133
  submitLabel: i18nKey("jobs.trigger.submit"),
134
134
  access: systemAdminAccess,
135
- description: "Manually trigger a job by name with an optional JSON object payload.",
135
+ description: "jobs.screen.job-trigger.subtitle",
136
136
  });
137
137
  r.screen({
138
138
  id: JOB_RUN_DETAIL_SCREEN_ID,
@@ -181,7 +181,7 @@ export function createJobsFeature(options = {}) {
181
181
  style: "primary",
182
182
  },
183
183
  ],
184
- description: "Detail view of one job run with status, timings, error and log lines, and a retry action for failed runs; reached from a row of the job-runs list.",
184
+ description: "jobs.screen.job-run-detail.subtitle",
185
185
  listScreenId: JOB_RUNS_SCREEN_ID,
186
186
  access: systemAdminAccess,
187
187
  });
package/dist/jobs/i18n.js CHANGED
@@ -4,6 +4,12 @@ export const JOBS_I18N = {
4
4
  "screen:job-runs.title": { en: "Job runs" },
5
5
  "screen:job-run-detail.title": { en: "Job run" },
6
6
  "screen:job-trigger.title": { en: "Trigger job" },
7
+ "jobs.screen.job-trigger.subtitle": {
8
+ en: "Start a job manually by name, with an optional JSON payload.",
9
+ },
10
+ "jobs.screen.job-run-detail.subtitle": {
11
+ en: "Status, timings, error and log lines of one job run.",
12
+ },
7
13
  "jobs:entity:__action-form__:field:jobName": { en: "Job name" },
8
14
  "jobs:entity:__action-form__:field:payload": { en: "JSON payload" },
9
15
  "jobs:nav.jobRuns": { en: "Jobs" },
@@ -40,6 +40,7 @@ export const jobRunEntity = createEntity({
40
40
  duration: createNumberField({ integer: true }),
41
41
  triggeredById: createTextField({ personal: false, reason: "pseudonymous_fk" }),
42
42
  },
43
+ indexes: [{ columns: ["startedAt"] }],
43
44
  });
44
45
  // Plain EntityTableMeta, NOT a branded EntityTable (buildEntityTable would
45
46
  // mark it executor-only): job-run is an unmanaged direct-write store, so
@@ -0,0 +1,13 @@
1
+ import type { WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
+ import type { Temporal } from "temporal-polyfill";
4
+ import { type MetricRange } from "./constants.js";
5
+ import type { MetricDefinition, MetricResult } from "./types.js";
6
+ export type MetricRunParams = {
7
+ readonly range: MetricRange;
8
+ readonly timeZone: string;
9
+ readonly now: Temporal.Instant;
10
+ /** Extra filter that narrows the source to the caller's scope. */
11
+ readonly scopeWhere: WhereObject;
12
+ };
13
+ export declare function runMetric(db: TenantDb, metric: MetricDefinition, params: MetricRunParams): Promise<MetricResult>;
@@ -0,0 +1,127 @@
1
+ import { MAX_GROUP_ROWS } from "./constants.js";
2
+ import { bucketStartsMs, computeWindow, resolveBucket } from "./window.js";
3
+ const NULL_KEY_TEXT = "—";
4
+ function keyText(key) {
5
+ return key === null || key === undefined ? NULL_KEY_TEXT : String(key);
6
+ }
7
+ function formatDelta(value, previousValue) {
8
+ if (value === null || previousValue === null || previousValue === 0 || value === previousValue) {
9
+ return { delta: null, deltaDirection: null };
10
+ }
11
+ const percent = Math.round((Math.abs(value - previousValue) / Math.abs(previousValue)) * 100);
12
+ if (percent === 0)
13
+ return { delta: null, deltaDirection: null };
14
+ return { delta: `${percent} %`, deltaDirection: value > previousValue ? "up" : "down" };
15
+ }
16
+ function fractionOf(value, total) {
17
+ if (total === null || total <= 0)
18
+ return 0;
19
+ return Math.min(1, Math.max(0, value / total));
20
+ }
21
+ function buildWheres(metric, window, scopeWhere) {
22
+ const baseWhere = { ...metric.where, ...scopeWhere };
23
+ const { timeField } = metric;
24
+ if (timeField === undefined || window === undefined) {
25
+ return { current: baseWhere, previous: undefined };
26
+ }
27
+ return {
28
+ current: { ...baseWhere, [timeField]: { gte: window.start, lte: window.end } },
29
+ previous: metric.comparePrevious === false
30
+ ? undefined
31
+ : { ...baseWhere, [timeField]: { gte: window.previousStart, lt: window.start } },
32
+ };
33
+ }
34
+ async function fetchAggregates(db, metric, wheres, bucketDimension) {
35
+ const aggregate = (groupBy, where, extra = {}) => db.aggregate(metric.source, { measure: metric.measure, groupBy, ...extra }, where);
36
+ const { groupBy, stackBy } = metric;
37
+ const { current } = wheres;
38
+ const [total, previous, points, groups, stacks, series] = await Promise.all([
39
+ aggregate([], current),
40
+ wheres.previous === undefined ? Promise.resolve(undefined) : aggregate([], wheres.previous),
41
+ bucketDimension !== undefined && groupBy === undefined
42
+ ? aggregate([bucketDimension], current)
43
+ : Promise.resolve([]),
44
+ groupBy === undefined
45
+ ? Promise.resolve([])
46
+ : aggregate([{ field: groupBy }], current, { orderByValue: "desc", limit: MAX_GROUP_ROWS }),
47
+ groupBy !== undefined && stackBy !== undefined
48
+ ? aggregate([{ field: groupBy }, { field: stackBy }], current)
49
+ : Promise.resolve([]),
50
+ bucketDimension !== undefined && groupBy !== undefined
51
+ ? aggregate([{ field: groupBy }, bucketDimension], current)
52
+ : Promise.resolve([]),
53
+ ]);
54
+ return { total, previous, points, groups, stacks, series };
55
+ }
56
+ function groupSegments(stackRows, labelOf) {
57
+ const segmentsByGroup = new Map();
58
+ for (const row of stackRows) {
59
+ const groupKey = keyText(row.keys[0]);
60
+ const stackKey = keyText(row.keys[1]);
61
+ const segments = segmentsByGroup.get(groupKey) ?? [];
62
+ segments.push({ key: stackKey, label: labelOf(stackKey), value: row.value ?? 0 });
63
+ segmentsByGroup.set(groupKey, segments);
64
+ }
65
+ return segmentsByGroup;
66
+ }
67
+ function buildRows(metric, aggregates, total, labelOf) {
68
+ const segmentsByGroup = groupSegments(aggregates.stacks, labelOf);
69
+ return aggregates.groups.map((row) => {
70
+ const key = keyText(row.keys[0]);
71
+ const rowValue = row.value ?? 0;
72
+ return {
73
+ id: key,
74
+ key,
75
+ label: labelOf(key),
76
+ value: rowValue,
77
+ fraction: fractionOf(rowValue, total),
78
+ ...(metric.stackBy !== undefined && { segments: segmentsByGroup.get(key) ?? [] }),
79
+ };
80
+ });
81
+ }
82
+ function buildSeries(rows, seriesRows, fillBuckets) {
83
+ const bucketsByGroup = new Map();
84
+ for (const row of seriesRows) {
85
+ const groupKey = keyText(row.keys[0]);
86
+ const buckets = bucketsByGroup.get(groupKey) ?? new Map();
87
+ buckets.set(Number(row.keys[1]), row.value);
88
+ bucketsByGroup.set(groupKey, buckets);
89
+ }
90
+ return rows.map((row) => ({
91
+ key: row.key,
92
+ label: row.label,
93
+ points: fillBuckets(bucketsByGroup.get(row.key) ?? new Map()),
94
+ }));
95
+ }
96
+ export async function runMetric(db, metric, params) {
97
+ const range = metric.window ?? params.range;
98
+ const bucket = resolveBucket(metric, range);
99
+ const { timeField } = metric;
100
+ const window = timeField === undefined ? undefined : computeWindow(range, bucket, params.timeZone, params.now);
101
+ const bucketDimension = timeField !== undefined && bucket !== undefined
102
+ ? { field: timeField, bucket, timeZone: params.timeZone }
103
+ : undefined;
104
+ const wheres = buildWheres(metric, window, params.scopeWhere);
105
+ const aggregates = await fetchAggregates(db, metric, wheres, bucketDimension);
106
+ const value = aggregates.total[0]?.value ?? null;
107
+ const previousValue = aggregates.previous?.[0]?.value ?? null;
108
+ const labelOf = (key) => metric.groupLabels?.[key] ?? key;
109
+ const gapValue = metric.measure.fn === "avg" ? null : 0;
110
+ const bucketStarts = window !== undefined && bucket !== undefined
111
+ ? bucketStartsMs(window, bucket, params.timeZone)
112
+ : [];
113
+ const fillBuckets = (byBucketMs) => bucketStarts.map((atMs) => ({ atMs, value: byBucketMs.get(atMs) ?? gapValue }));
114
+ const points = fillBuckets(new Map(aggregates.points.map((row) => [Number(row.keys[0]), row.value])));
115
+ const rows = buildRows(metric, aggregates, value, labelOf);
116
+ return {
117
+ value,
118
+ previousValue,
119
+ ...formatDelta(value, previousValue),
120
+ windowStartMs: window?.start.epochMilliseconds ?? null,
121
+ windowEndMs: window?.end.epochMilliseconds ?? null,
122
+ points,
123
+ series: buildSeries(rows, aggregates.series, fillBuckets),
124
+ rows,
125
+ nextCursor: null,
126
+ };
127
+ }
@@ -0,0 +1,7 @@
1
+ export declare const METRICS_FEATURE: "metrics";
2
+ export declare const METRICS_SYSTEM_FEATURE: "metrics-system";
3
+ export type MetricScope = "system" | "tenant";
4
+ export declare function metricQueryName(scope: MetricScope, id: string): string;
5
+ export declare const METRIC_RANGES: readonly ["24h", "7d", "14d", "30d"];
6
+ export type MetricRange = (typeof METRIC_RANGES)[number];
7
+ export declare const MAX_GROUP_ROWS = 50;
@@ -0,0 +1,14 @@
1
+ // @runtime client
2
+ // Pure constants — imported by server (feature.ts) and by panel declarations,
3
+ // so no runtime import from the engine barrel here.
4
+ export const METRICS_FEATURE = "metrics";
5
+ export const METRICS_SYSTEM_FEATURE = "metrics-system";
6
+ const FEATURE_BY_SCOPE = {
7
+ tenant: METRICS_FEATURE,
8
+ system: METRICS_SYSTEM_FEATURE,
9
+ };
10
+ export function metricQueryName(scope, id) {
11
+ return `${FEATURE_BY_SCOPE[scope]}:query:${id}`;
12
+ }
13
+ export const METRIC_RANGES = ["24h", "7d", "14d", "30d"];
14
+ export const MAX_GROUP_ROWS = 50;
@@ -0,0 +1,9 @@
1
+ export declare const jobRunsByStatusMetric: import("./types.js").MetricDefinition;
2
+ export declare const failedJobRunsMetric: import("./types.js").MetricDefinition;
3
+ export declare const tenantJobFailuresMetric: import("./types.js").MetricDefinition;
4
+ export declare const deliveriesByChannelMetric: import("./types.js").MetricDefinition;
5
+ export declare const failedDeliveriesMetric: import("./types.js").MetricDefinition;
6
+ export declare const activeUsersMetric: import("./types.js").MetricDefinition;
7
+ export declare const activeTenantsMetric: import("./types.js").MetricDefinition;
8
+ export declare const auditWritesMetric: import("./types.js").MetricDefinition;
9
+ export declare const DEFAULT_METRICS: readonly [import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition, import("./types.js").MetricDefinition];
@@ -0,0 +1,115 @@
1
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
2
+ import { DELIVERY_FEATURE, DeliveryStatus, deliveryAttemptsTable } from "../delivery/index.js";
3
+ import { JOBS_FEATURE, jobRunsTable, tenantJobFailuresTable, } from "../jobs/index.js";
4
+ import { SESSIONS_FEATURE, userSessionTable } from "../sessions/index.js";
5
+ import { TENANT_FEATURE, tenantTable } from "../tenant/index.js";
6
+ import { defineMetric } from "./types.js";
7
+ const JOB_STATUSES = ["queued", "running", "completed", "failed"];
8
+ function labelKeys(prefix, keys) {
9
+ return Object.fromEntries(keys.map((key) => [key, `${prefix}.${key}`]));
10
+ }
11
+ const JOB_STATUS_LABELS = labelKeys("metrics:jobStatus", JOB_STATUSES);
12
+ const DELIVERY_STATUS_LABELS = labelKeys("metrics:deliveryStatus", Object.values(DeliveryStatus));
13
+ // Channel ids are open (extension point), so only the bundled ones are mapped;
14
+ // unknown channels fall back to their raw id. Shared map: labelOf serves groupBy and stackBy.
15
+ const DELIVERY_CHANNEL_LABELS = labelKeys("metrics:deliveryChannel", ["inApp", "email", "push"]);
16
+ export const jobRunsByStatusMetric = defineMetric({
17
+ id: "job-runs-by-status",
18
+ description: "Counts job runs started in the window, split by status over time.",
19
+ source: jobRunsTable,
20
+ requires: JOBS_FEATURE,
21
+ measure: { fn: "count" },
22
+ timeField: "startedAt",
23
+ bucket: "auto",
24
+ groupBy: "status",
25
+ groupLabels: JOB_STATUS_LABELS,
26
+ scopes: ["system"],
27
+ });
28
+ export const failedJobRunsMetric = defineMetric({
29
+ id: "failed-job-runs",
30
+ description: "Counts failed job runs over the last 24 hours, per hour.",
31
+ source: jobRunsTable,
32
+ requires: JOBS_FEATURE,
33
+ measure: { fn: "count" },
34
+ where: { status: "failed" },
35
+ timeField: "startedAt",
36
+ window: "24h",
37
+ bucket: "hour",
38
+ scopes: ["system"],
39
+ });
40
+ export const tenantJobFailuresMetric = defineMetric({
41
+ id: "tenant-job-failures",
42
+ description: "Counts tenant-attributed job failures over the last 24 hours, per hour.",
43
+ source: tenantJobFailuresTable,
44
+ requires: JOBS_FEATURE,
45
+ measure: { fn: "count" },
46
+ timeField: "failedAt",
47
+ window: "24h",
48
+ bucket: "hour",
49
+ scopes: ["tenant", "system"],
50
+ });
51
+ export const deliveriesByChannelMetric = defineMetric({
52
+ id: "deliveries-by-channel",
53
+ description: "Counts notification delivery attempts in the window per channel, stacked by status.",
54
+ source: deliveryAttemptsTable,
55
+ requires: DELIVERY_FEATURE,
56
+ measure: { fn: "count" },
57
+ timeField: "createdAt",
58
+ groupBy: "channel",
59
+ stackBy: "status",
60
+ groupLabels: { ...DELIVERY_CHANNEL_LABELS, ...DELIVERY_STATUS_LABELS },
61
+ scopes: ["tenant", "system"],
62
+ });
63
+ export const failedDeliveriesMetric = defineMetric({
64
+ id: "failed-deliveries",
65
+ description: "Counts failed notification delivery attempts over the last 24 hours, per hour.",
66
+ source: deliveryAttemptsTable,
67
+ requires: DELIVERY_FEATURE,
68
+ measure: { fn: "count" },
69
+ where: { status: DeliveryStatus.failed },
70
+ timeField: "createdAt",
71
+ window: "24h",
72
+ bucket: "hour",
73
+ scopes: ["tenant", "system"],
74
+ });
75
+ export const activeUsersMetric = defineMetric({
76
+ id: "active-users",
77
+ description: "Counts distinct users with session activity over the last 7 days, per day.",
78
+ source: userSessionTable,
79
+ requires: SESSIONS_FEATURE,
80
+ measure: { fn: "countDistinct", field: "userId" },
81
+ timeField: "lastSeenAt",
82
+ window: "7d",
83
+ bucket: "day",
84
+ comparePrevious: false,
85
+ scopes: ["tenant", "system"],
86
+ });
87
+ export const activeTenantsMetric = defineMetric({
88
+ id: "active-tenants",
89
+ description: "Counts tenants that are enabled and in the active lifecycle state.",
90
+ source: tenantTable,
91
+ requires: TENANT_FEATURE,
92
+ measure: { fn: "count" },
93
+ where: { isEnabled: true, status: "active" },
94
+ scopes: ["system"],
95
+ });
96
+ export const auditWritesMetric = defineMetric({
97
+ id: "audit-writes",
98
+ description: "Counts event-store writes over the last 14 days, per day.",
99
+ source: eventsTable,
100
+ measure: { fn: "count" },
101
+ timeField: "createdAt",
102
+ window: "14d",
103
+ bucket: "day",
104
+ scopes: ["tenant", "system"],
105
+ });
106
+ export const DEFAULT_METRICS = [
107
+ jobRunsByStatusMetric,
108
+ failedJobRunsMetric,
109
+ tenantJobFailuresMetric,
110
+ deliveriesByChannelMetric,
111
+ failedDeliveriesMetric,
112
+ activeUsersMetric,
113
+ activeTenantsMetric,
114
+ auditWritesMetric,
115
+ ];
@@ -0,0 +1,4 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { MetricsFeatureOptions } from "./types.js";
3
+ export declare function createMetricsFeature(options: MetricsFeatureOptions): FeatureDefinition;
4
+ export declare function createSystemMetricsFeature(options: MetricsFeatureOptions): FeatureDefinition;
@@ -0,0 +1,119 @@
1
+ import { access, defineFeature, defineQueryHandler, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import * as z from "zod";
5
+ import { runMetric } from "./compute.js";
6
+ import { METRIC_RANGES, METRICS_FEATURE, METRICS_SYSTEM_FEATURE, } from "./constants.js";
7
+ import { METRICS_I18N } from "./i18n.js";
8
+ import { metricHasTenantColumn, validateMetrics } from "./validate.js";
9
+ import { isValidTimeZone } from "./window.js";
10
+ const DEFAULT_RANGE = "7d";
11
+ const DEFAULT_TIME_ZONE = "UTC";
12
+ const windowFields = {
13
+ range: z.enum(METRIC_RANGES).default(DEFAULT_RANGE),
14
+ timeZone: z.string().default(DEFAULT_TIME_ZONE),
15
+ };
16
+ const invalidTimeZone = {
17
+ path: ["timeZone"],
18
+ message: "Unknown IANA time zone",
19
+ };
20
+ const tenantPayloadSchema = z
21
+ .object(windowFields)
22
+ .refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
23
+ const systemPayloadSchema = z
24
+ .object({ ...windowFields, tenantId: z.uuid().optional() })
25
+ .refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
26
+ const defaultClock = () => Temporal.Now.instant();
27
+ function metricsForScope(metrics, scope) {
28
+ return metrics.filter((metric) => metric.scopes.includes(scope));
29
+ }
30
+ function requiredFeatures(metrics) {
31
+ const features = metrics.flatMap((metric) => metric.requires === undefined ? [] : [metric.requires]);
32
+ return [...new Set(features)];
33
+ }
34
+ function createTenantMetricQuery(metric, now) {
35
+ return defineQueryHandler({
36
+ name: metric.id,
37
+ description: metric.description,
38
+ schema: tenantPayloadSchema,
39
+ access: { roles: access.admin },
40
+ handler: (query, ctx) => runMetric(ctx.db, metric, {
41
+ range: query.payload.range,
42
+ timeZone: query.payload.timeZone,
43
+ now: now(),
44
+ // ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
45
+ scopeWhere: { tenantId: query.user.tenantId },
46
+ }),
47
+ });
48
+ }
49
+ function createSystemMetricQuery(metric, now) {
50
+ return defineQueryHandler({
51
+ name: metric.id,
52
+ description: metric.description,
53
+ schema: systemPayloadSchema,
54
+ access: { roles: access.systemAdmin },
55
+ handler: (query, ctx) => {
56
+ if (!ctx.systemDb) {
57
+ throw new InternalError({
58
+ message: `${METRICS_SYSTEM_FEATURE}:query:${metric.id} requires ctx.systemDb — is r.systemScope() set?`,
59
+ });
60
+ }
61
+ const { tenantId } = query.payload;
62
+ if (tenantId !== undefined && !metricHasTenantColumn(metric)) {
63
+ throw new ValidationError({
64
+ fields: [
65
+ {
66
+ path: "tenantId",
67
+ code: "unsupported",
68
+ i18nKey: "metrics.errors.tenantFilterUnsupported",
69
+ },
70
+ ],
71
+ });
72
+ }
73
+ const db = ctx.systemDb.acknowledgeCrossTenant(`${METRICS_SYSTEM_FEATURE}:${metric.id} — SystemAdmin platform-wide aggregate`);
74
+ return runMetric(db, metric, {
75
+ range: query.payload.range,
76
+ timeZone: query.payload.timeZone,
77
+ now: now(),
78
+ scopeWhere: tenantId === undefined ? {} : { tenantId },
79
+ });
80
+ },
81
+ });
82
+ }
83
+ export function createMetricsFeature(options) {
84
+ validateMetrics(options.metrics);
85
+ const metrics = metricsForScope(options.metrics, "tenant");
86
+ const now = options.now ?? defaultClock;
87
+ return defineFeature(METRICS_FEATURE, (r) => {
88
+ r.describe("Declarative tenant-scoped metrics: each metric declaration becomes an admin-only query that aggregates a read-model table for the caller's own tenant and returns the shape dashboard panels consume (stat value with period delta, time series, ranked rows).");
89
+ r.uiHints({
90
+ displayLabel: "Metrics · Tenant Dashboard Data",
91
+ category: "operations",
92
+ recommended: false,
93
+ });
94
+ for (const feature of requiredFeatures(metrics))
95
+ r.requires(feature);
96
+ for (const metric of metrics)
97
+ r.queryHandler(createTenantMetricQuery(metric, now));
98
+ r.translations({ keys: METRICS_I18N });
99
+ });
100
+ }
101
+ export function createSystemMetricsFeature(options) {
102
+ validateMetrics(options.metrics);
103
+ const metrics = metricsForScope(options.metrics, "system");
104
+ const now = options.now ?? defaultClock;
105
+ return defineFeature(METRICS_SYSTEM_FEATURE, (r) => {
106
+ r.describe("Declarative platform-wide metrics for SystemAdmin: each metric declaration becomes a query that aggregates a read-model table across all tenants (optionally narrowed to one tenant) and returns the shape dashboard panels consume.");
107
+ r.uiHints({
108
+ displayLabel: "Metrics · Platform Dashboard Data",
109
+ category: "operations",
110
+ recommended: false,
111
+ });
112
+ r.systemScope();
113
+ for (const feature of requiredFeatures(metrics))
114
+ r.requires(feature);
115
+ for (const metric of metrics)
116
+ r.queryHandler(createSystemMetricQuery(metric, now));
117
+ r.translations({ keys: METRICS_I18N });
118
+ });
119
+ }
@@ -0,0 +1,5 @@
1
+ type LocalizedString = {
2
+ readonly en: string;
3
+ };
4
+ export declare const METRICS_I18N: Readonly<Record<string, LocalizedString>>;
5
+ export {};
@@ -0,0 +1,16 @@
1
+ export const METRICS_I18N = {
2
+ "metrics:jobStatus.queued": { en: "Queued" },
3
+ "metrics:jobStatus.running": { en: "Running" },
4
+ "metrics:jobStatus.completed": { en: "Completed" },
5
+ "metrics:jobStatus.failed": { en: "Failed" },
6
+ "metrics:deliveryStatus.queued": { en: "Queued" },
7
+ "metrics:deliveryStatus.sent": { en: "Sent" },
8
+ "metrics:deliveryStatus.failed": { en: "Failed" },
9
+ "metrics:deliveryStatus.skipped": { en: "Skipped" },
10
+ "metrics:deliveryChannel.inApp": { en: "In-app" },
11
+ "metrics:deliveryChannel.email": { en: "Email" },
12
+ "metrics:deliveryChannel.push": { en: "Push" },
13
+ "metrics.errors.tenantFilterUnsupported": {
14
+ en: "This metric's source has no tenant column, so it cannot be narrowed to one tenant.",
15
+ },
16
+ };
@@ -0,0 +1,5 @@
1
+ export { METRIC_RANGES, METRICS_FEATURE, METRICS_SYSTEM_FEATURE, type MetricRange, type MetricScope, metricQueryName, } from "./constants.js";
2
+ export { activeTenantsMetric, activeUsersMetric, auditWritesMetric, DEFAULT_METRICS, deliveriesByChannelMetric, failedDeliveriesMetric, failedJobRunsMetric, jobRunsByStatusMetric, tenantJobFailuresMetric, } from "./default-metrics.js";
3
+ export { createMetricsFeature, createSystemMetricsFeature } from "./feature.js";
4
+ export { METRICS_I18N } from "./i18n.js";
5
+ export { defineMetric, type MetricDefinition, type MetricPoint, type MetricResult, type MetricRow, type MetricSegment, type MetricSeries, type MetricsFeatureOptions, } from "./types.js";
@@ -0,0 +1,6 @@
1
+ // Public API of the metrics bundled-feature.
2
+ export { METRIC_RANGES, METRICS_FEATURE, METRICS_SYSTEM_FEATURE, metricQueryName, } from "./constants.js";
3
+ export { activeTenantsMetric, activeUsersMetric, auditWritesMetric, DEFAULT_METRICS, deliveriesByChannelMetric, failedDeliveriesMetric, failedJobRunsMetric, jobRunsByStatusMetric, tenantJobFailuresMetric, } from "./default-metrics.js";
4
+ export { createMetricsFeature, createSystemMetricsFeature } from "./feature.js";
5
+ export { METRICS_I18N } from "./i18n.js";
6
+ export { defineMetric, } from "./types.js";
@@ -0,0 +1,70 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { AggregateMeasure, AggregateTimeBucket } from "@cosmicdrift/kumiko-types/aggregate-types";
3
+ import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
4
+ import type { WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
5
+ import type { Temporal } from "temporal-polyfill";
6
+ import type { MetricRange, MetricScope } from "./constants.js";
7
+ export type MetricDefinition = {
8
+ readonly id: string;
9
+ readonly description: string;
10
+ readonly source: SchemaTable | EntityTableMeta;
11
+ /** Feature that owns the source table; becomes `r.requires(...)`. */
12
+ readonly requires?: string;
13
+ readonly measure: AggregateMeasure;
14
+ readonly where?: WhereObject;
15
+ /** timestamptz column; without it the metric is a snapshot with no window. */
16
+ readonly timeField?: string;
17
+ /** Fixed window, ignores the payload range. */
18
+ readonly window?: MetricRange;
19
+ /** "auto": 24h → hour, otherwise day. */
20
+ readonly bucket?: AggregateTimeBucket | "auto";
21
+ /** Default true; false skips the previous-period query (no delta), for measures the previous window cannot answer, e.g. overwritten lastSeenAt. */
22
+ readonly comparePrevious?: boolean;
23
+ readonly groupBy?: string;
24
+ /** Second dimension, becomes `rows[].segments`; only without bucket. */
25
+ readonly stackBy?: string;
26
+ /** Key value → i18n key, applied to groupBy and stackBy keys. */
27
+ readonly groupLabels?: Readonly<Record<string, string>>;
28
+ readonly scopes: readonly MetricScope[];
29
+ };
30
+ export declare function defineMetric(definition: MetricDefinition): MetricDefinition;
31
+ export type MetricPoint = {
32
+ readonly atMs: number;
33
+ readonly value: number | null;
34
+ };
35
+ export type MetricSeries = {
36
+ readonly key: string;
37
+ readonly label: string;
38
+ readonly points: readonly MetricPoint[];
39
+ };
40
+ export type MetricSegment = {
41
+ readonly key: string;
42
+ readonly label: string;
43
+ readonly value: number;
44
+ };
45
+ export type MetricRow = {
46
+ readonly id: string;
47
+ readonly key: string;
48
+ readonly label: string;
49
+ readonly value: number;
50
+ readonly fraction: number;
51
+ readonly segments?: readonly MetricSegment[];
52
+ };
53
+ export type MetricDeltaDirection = "up" | "down";
54
+ export type MetricResult = {
55
+ readonly value: number | null;
56
+ readonly previousValue: number | null;
57
+ readonly delta: string | null;
58
+ readonly deltaDirection: MetricDeltaDirection | null;
59
+ readonly windowStartMs: number | null;
60
+ readonly windowEndMs: number | null;
61
+ readonly points: readonly MetricPoint[];
62
+ readonly series: readonly MetricSeries[];
63
+ readonly rows: readonly MetricRow[];
64
+ readonly nextCursor: null;
65
+ };
66
+ export type MetricsFeatureOptions = {
67
+ readonly metrics: readonly MetricDefinition[];
68
+ /** Fixed clock for deterministic tests. */
69
+ readonly now?: () => Temporal.Instant;
70
+ };
@@ -0,0 +1,3 @@
1
+ export function defineMetric(definition) {
2
+ return definition;
3
+ }
@@ -0,0 +1,3 @@
1
+ import type { MetricDefinition } from "./types.js";
2
+ export declare function metricHasTenantColumn(metric: MetricDefinition): boolean;
3
+ export declare function validateMetrics(metrics: readonly MetricDefinition[]): void;
@@ -0,0 +1,74 @@
1
+ import { extractTableInfo, isTimestamptzType } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { METRIC_RANGES } from "./constants.js";
3
+ const KEBAB_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
4
+ const TENANT_ID_FIELD = "tenantId";
5
+ export function metricHasTenantColumn(metric) {
6
+ return extractTableInfo(metric.source).hasColumn(TENANT_ID_FIELD);
7
+ }
8
+ export function validateMetrics(metrics) {
9
+ const seen = new Set();
10
+ for (const metric of metrics) {
11
+ if (seen.has(metric.id)) {
12
+ throw new Error(`metrics: duplicate metric id "${metric.id}"`);
13
+ }
14
+ seen.add(metric.id);
15
+ validateMetric(metric);
16
+ }
17
+ }
18
+ function validateMetric(metric) {
19
+ const fail = (reason) => {
20
+ throw new Error(`metrics: metric "${metric.id}" ${reason}`);
21
+ };
22
+ if (!KEBAB_ID.test(metric.id))
23
+ fail("has an id that is not kebab-case");
24
+ if (metric.scopes.length === 0)
25
+ fail("declares no scopes");
26
+ const info = extractTableInfo(metric.source);
27
+ validateColumns(metric, info, fail);
28
+ validateTimeSettings(metric, info, fail);
29
+ validateShape(metric, fail);
30
+ if (metric.scopes.includes("tenant") && !metricHasTenantColumn(metric)) {
31
+ fail(`has scope "tenant" but ${info.name} has no tenant_id column`);
32
+ }
33
+ }
34
+ function validateColumns(metric, info, fail) {
35
+ const requireColumn = (field, role) => {
36
+ if (!info.hasColumn(field))
37
+ fail(`${role} "${field}" is not a column of ${info.name}`);
38
+ };
39
+ if (metric.measure.fn !== "count")
40
+ requireColumn(metric.measure.field, "measure field");
41
+ for (const field of Object.keys(metric.where ?? {}))
42
+ requireColumn(field, "where field");
43
+ if (metric.groupBy !== undefined)
44
+ requireColumn(metric.groupBy, "groupBy field");
45
+ if (metric.stackBy !== undefined)
46
+ requireColumn(metric.stackBy, "stackBy field");
47
+ if (metric.timeField !== undefined)
48
+ requireColumn(metric.timeField, "timeField");
49
+ }
50
+ function validateTimeSettings(metric, info, fail) {
51
+ const { timeField } = metric;
52
+ if (timeField === undefined) {
53
+ if (metric.bucket !== undefined)
54
+ fail("sets bucket without timeField");
55
+ if (metric.window !== undefined)
56
+ fail("sets window without timeField");
57
+ }
58
+ else {
59
+ if (!isTimestamptzType(info.pgTypeOf(info.columnOf(timeField)))) {
60
+ fail(`timeField "${timeField}" is not a timestamptz column`);
61
+ }
62
+ if (metric.window !== undefined && !METRIC_RANGES.includes(metric.window)) {
63
+ fail(`has an unknown window "${metric.window}"`);
64
+ }
65
+ }
66
+ }
67
+ function validateShape(metric, fail) {
68
+ if (metric.stackBy !== undefined) {
69
+ if (metric.groupBy === undefined)
70
+ fail("sets stackBy without groupBy");
71
+ if (metric.bucket !== undefined)
72
+ fail("combines stackBy with bucket");
73
+ }
74
+ }