@cosmicdrift/kumiko-samples 0.383.1 → 0.385.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 (83) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/event-classification.ts +83 -0
  5. package/packages/bundled-features/src/audit/feature.ts +3 -2
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
  7. package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
  8. package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
  9. package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
  11. package/packages/bundled-features/src/audit/screens.ts +32 -4
  12. package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
  13. package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
  14. package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
  15. package/packages/bundled-features/src/audit/web/constants.ts +11 -0
  16. package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
  17. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  18. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
  20. package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
  21. package/packages/bundled-features/src/auth-email-password/password-policy.ts +196 -2087
  22. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +9 -12
  23. package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
  24. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
  25. package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
  26. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
  27. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
  28. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  29. package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
  30. package/packages/bundled-features/src/file-derivatives/feature.ts +1 -1
  31. package/packages/bundled-features/src/jobs/changes.json +6 -0
  32. package/packages/bundled-features/src/jobs/feature.ts +34 -127
  33. package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
  34. package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
  35. package/packages/bundled-features/src/jobs/screens.ts +262 -0
  36. package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
  37. package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
  38. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  39. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  40. package/packages/bundled-features/src/personal-access-tokens/changes.json +7 -0
  41. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +4 -0
  42. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -0
  43. package/packages/bundled-features/src/secrets/i18n.ts +0 -3
  44. package/packages/bundled-features/src/sessions/changes.json +6 -0
  45. package/packages/bundled-features/src/sessions/constants.ts +3 -1
  46. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
  47. package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
  48. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
  49. package/packages/bundled-features/src/sessions/screens.ts +23 -8
  50. package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
  51. package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
  52. package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
  53. package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
  54. package/packages/bundled-features/src/sessions/web/index.ts +1 -0
  55. package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
  56. package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
  57. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +1 -1
  58. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  59. package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
  60. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
  61. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
  62. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
  63. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
  64. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  65. package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
  66. package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
  67. package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
  68. package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
  69. package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
  70. package/samples/apps/use-all-bundled/bin/main.ts +7 -1
  71. package/samples/apps/use-all-bundled/feature-manifest.json +19 -1
  72. package/samples/apps/use-all-bundled/kumiko/schema.ts +3 -1
  73. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  74. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +4 -2
  75. package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
  76. package/samples/apps/use-all-bundled/src/app/pat-scope-labels-feature.ts +21 -0
  77. package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
  78. package/samples/apps/use-all-bundled/src/run-config.ts +5 -0
  79. package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
  80. package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
  81. package/samples/recipes/designer-demo/src/feature.ts +6 -2
  82. package/samples/recipes/i18n/README.md +15 -0
  83. package/samples/recipes/i18n/src/feature.ts +4 -0
@@ -0,0 +1,137 @@
1
+ import { defineQueryHandler, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import {
4
+ type ConsumerProgress,
5
+ getAllConsumerProgress,
6
+ getConsumerState,
7
+ SEARCH_CONSUMER_NAME,
8
+ SSE_BROADCAST_CONSUMER_NAME,
9
+ } from "@cosmicdrift/kumiko-framework/pipeline";
10
+ import * as z from "zod";
11
+ import {
12
+ CONSUMER_ID_SEPARATOR,
13
+ CONSUMER_STATUSES,
14
+ type ConsumerStatus,
15
+ isConsumerStatus,
16
+ } from "../web/constants.ts";
17
+
18
+ const LAST_ERROR_PREVIEW_LENGTH = 90;
19
+
20
+ // Dead consumers are what support looks for, so they lead the default order.
21
+ const STATUS_RANK: Readonly<Record<ConsumerStatus, number>> = {
22
+ dead: 0,
23
+ disabled: 1,
24
+ processing: 2,
25
+ "never-run": 3,
26
+ idle: 4,
27
+ };
28
+
29
+ export function consumerRowId(name: string, instanceId: string): string {
30
+ return `${name}${CONSUMER_ID_SEPARATOR}${instanceId}`;
31
+ }
32
+
33
+ export function splitConsumerRowId(id: string): { name: string; instanceId: string } | null {
34
+ const at = id.lastIndexOf(CONSUMER_ID_SEPARATOR);
35
+ if (at <= 0) return null;
36
+ return { name: id.slice(0, at), instanceId: id.slice(at + 1) };
37
+ }
38
+
39
+ function registeredConsumerNames(registry: Registry): readonly string[] {
40
+ return [
41
+ SSE_BROADCAST_CONSUMER_NAME,
42
+ SEARCH_CONSUMER_NAME,
43
+ ...registry.getAllMultiStreamProjections().keys(),
44
+ ];
45
+ }
46
+
47
+ function previewOf(error: string | null): string | null {
48
+ if (error === null || error.length <= LAST_ERROR_PREVIEW_LENGTH) return error;
49
+ return `${error.slice(0, LAST_ERROR_PREVIEW_LENGTH).trimEnd()}…`;
50
+ }
51
+
52
+ function toRow(c: ConsumerProgress) {
53
+ return {
54
+ id: consumerRowId(c.name, c.instanceId),
55
+ name: c.name,
56
+ instanceId: c.instanceId,
57
+ status: c.status,
58
+ lag: Number(c.lag),
59
+ attempts: c.attempts,
60
+ lastError: previewOf(c.lastError),
61
+ };
62
+ }
63
+
64
+ const statusFilterSchema = z.strictObject({
65
+ field: z.literal("status"),
66
+ op: z.literal("in"),
67
+ value: z.array(z.enum(CONSUMER_STATUSES)),
68
+ });
69
+
70
+ export const consumersQuery = defineQueryHandler({
71
+ name: "consumers",
72
+ description:
73
+ "Lists every event consumer (projections, search indexer, SSE broadcaster) with status, lag behind the event store and last error, dead consumers first; use it to find a stuck consumer.",
74
+ schema: z.object({
75
+ filters: z.array(statusFilterSchema).max(1).optional(),
76
+ sort: z.enum(["name", "status", "lag", "attempts"]).optional(),
77
+ sortDirection: z.enum(["asc", "desc"]).optional(),
78
+ }),
79
+ access: { roles: ["SystemAdmin"] },
80
+ handler: async (query, ctx) => {
81
+ if (!ctx.systemDb) {
82
+ throw new InternalError({
83
+ message:
84
+ "jobs:query:consumers requires ctx.systemDb (feature must declare r.systemScope())",
85
+ });
86
+ }
87
+ const db = ctx.systemDb.unsafeRaw(
88
+ "event consumer state is global dispatcher state, not tenant data",
89
+ );
90
+ const progress = await getAllConsumerProgress(db, registeredConsumerNames(ctx.registry));
91
+ const statuses = query.payload.filters?.[0]?.value;
92
+ const visible = statuses
93
+ ? progress.filter((c) => statuses.some((s) => s === c.status))
94
+ : progress;
95
+ const direction = query.payload.sortDirection === "desc" ? -1 : 1;
96
+ const sort = query.payload.sort;
97
+ const compare = (a: ConsumerProgress, b: ConsumerProgress): number => {
98
+ if (sort === "name") return a.name.localeCompare(b.name) * direction;
99
+ if (sort === "lag") return Number(a.lag - b.lag) * direction;
100
+ if (sort === "attempts") return (a.attempts - b.attempts) * direction;
101
+ const rank = (c: ConsumerProgress) =>
102
+ isConsumerStatus(c.status) ? STATUS_RANK[c.status] : 5;
103
+ return (rank(a) - rank(b)) * direction || a.name.localeCompare(b.name);
104
+ };
105
+ return { rows: [...visible].sort(compare).map(toRow), nextCursor: null };
106
+ },
107
+ });
108
+
109
+ export const consumerDetailQuery = defineQueryHandler({
110
+ name: "consumer-details",
111
+ description:
112
+ "Returns one event consumer with its status, lag, attempts, the full last error and the time of its last state change; use it to decide between restart, skip and pause.",
113
+ schema: z.object({ consumerId: z.string().min(1) }),
114
+ access: { roles: ["SystemAdmin"] },
115
+ handler: async (query, ctx) => {
116
+ if (!ctx.systemDb) {
117
+ throw new InternalError({
118
+ message:
119
+ "jobs:query:consumer-details requires ctx.systemDb (feature must declare r.systemScope())",
120
+ });
121
+ }
122
+ const target = splitConsumerRowId(query.payload.consumerId);
123
+ if (!target) return null;
124
+ const db = ctx.systemDb.unsafeRaw(
125
+ "event consumer state is global dispatcher state, not tenant data",
126
+ );
127
+ const all = await getAllConsumerProgress(db, registeredConsumerNames(ctx.registry));
128
+ const progress = all.find((c) => c.name === target.name && c.instanceId === target.instanceId);
129
+ if (!progress) return null;
130
+ const state = await getConsumerState(db, target.name, target.instanceId);
131
+ return {
132
+ ...toRow(progress),
133
+ lastError: progress.lastError,
134
+ updatedAt: state ? state.updatedAt.toString() : null,
135
+ };
136
+ },
137
+ });
@@ -0,0 +1,262 @@
1
+ import {
2
+ type ActionFormScreenDefinition,
3
+ i18nKey,
4
+ type ProjectionDetailScreenDefinition,
5
+ type ProjectionListScreenDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ CONSUMER_INSTANCE_OPTION_KEY_PREFIX,
9
+ CONSUMER_STATUS_OPTION_KEY_PREFIX,
10
+ CONSUMER_STATUSES,
11
+ EVENT_CONSUMER_DETAIL_SCREEN_ID,
12
+ EVENT_CONSUMERS_SCREEN_ID,
13
+ JOB_RUN_DETAIL_SCREEN_ID,
14
+ JOB_RUNS_SCREEN_ID,
15
+ JobHandlers,
16
+ JobQueries,
17
+ } from "./web/constants.ts";
18
+
19
+ const systemAdminAccess = { roles: ["SystemAdmin"] as const };
20
+
21
+ export const jobRunsScreen: ProjectionListScreenDefinition = {
22
+ id: JOB_RUNS_SCREEN_ID,
23
+ type: "projectionList",
24
+ query: JobQueries.list,
25
+ columns: [
26
+ { field: "jobName", label: i18nKey("jobs.runs.col.job") },
27
+ { field: "status", label: i18nKey("jobs.runs.col.status") },
28
+ {
29
+ field: "startedAt",
30
+ label: i18nKey("jobs.runs.col.started"),
31
+ renderer: { format: "timestamp" },
32
+ },
33
+ { field: "duration", label: i18nKey("jobs.runs.col.duration") },
34
+ ],
35
+ defaultSort: { field: "startedAt", dir: "desc" },
36
+ facets: [
37
+ {
38
+ field: "status",
39
+ type: "select",
40
+ label: i18nKey("jobs.runs.filter.status"),
41
+ options: [
42
+ { value: "queued", label: i18nKey("jobs.runs.filter.queued") },
43
+ { value: "running", label: i18nKey("jobs.runs.filter.running") },
44
+ { value: "completed", label: i18nKey("jobs.runs.filter.completed") },
45
+ { value: "failed", label: i18nKey("jobs.runs.filter.failed") },
46
+ ],
47
+ },
48
+ ],
49
+ rowActions: [
50
+ {
51
+ kind: "navigate",
52
+ id: "open",
53
+ label: i18nKey("jobs.runs.open"),
54
+ screen: JOB_RUN_DETAIL_SCREEN_ID,
55
+ entityId: "id",
56
+ rowClick: true,
57
+ },
58
+ ],
59
+ toolbarActions: [
60
+ {
61
+ kind: "drawer",
62
+ id: "trigger",
63
+ label: i18nKey("jobs.trigger.title"),
64
+ screen: "job-trigger",
65
+ style: "primary",
66
+ },
67
+ ],
68
+ pagination: "infinite",
69
+ access: systemAdminAccess,
70
+ };
71
+
72
+ export const jobTriggerScreen: ActionFormScreenDefinition = {
73
+ id: "job-trigger",
74
+ type: "actionForm",
75
+ handler: JobHandlers.trigger,
76
+ fields: {
77
+ jobName: { type: "text", required: true },
78
+ payload: { type: "longText", multiline: true, default: "{}" },
79
+ },
80
+ layout: { sections: [{ fields: ["jobName", "payload"] }] },
81
+ submitLabel: i18nKey("jobs.trigger.submit"),
82
+ access: systemAdminAccess,
83
+ description: "jobs.screen.job-trigger.subtitle",
84
+ };
85
+
86
+ export const jobRunDetailScreen: ProjectionDetailScreenDefinition = {
87
+ id: JOB_RUN_DETAIL_SCREEN_ID,
88
+ type: "projectionDetail",
89
+ query: JobQueries.details,
90
+ idParam: "runId",
91
+ fieldLabels: {
92
+ jobName: i18nKey("jobs.detail.field.job"),
93
+ status: i18nKey("jobs.detail.field.status"),
94
+ id: i18nKey("jobs.detail.field.id"),
95
+ startedAt: i18nKey("jobs.detail.field.started"),
96
+ finishedAt: i18nKey("jobs.detail.field.finished"),
97
+ duration: i18nKey("jobs.detail.field.duration"),
98
+ error: i18nKey("jobs.detail.field.error"),
99
+ logs: i18nKey("jobs.detail.logs"),
100
+ },
101
+ layout: {
102
+ sections: [
103
+ {
104
+ fields: [
105
+ "jobName",
106
+ "status",
107
+ "id",
108
+ { field: "startedAt", renderer: { format: "timestamp" } },
109
+ { field: "finishedAt", renderer: { format: "timestamp" } },
110
+ "duration",
111
+ "error",
112
+ ],
113
+ },
114
+ // No section `title` here (fw#2312 label-dedup fix): the section
115
+ // holds exactly one field, so a title would repeat the field's own
116
+ // label (rendered by RenderField as the Field's heading) verbatim.
117
+ {
118
+ fields: [{ field: "logs", renderer: { format: "json" } }],
119
+ },
120
+ ],
121
+ },
122
+ actions: [
123
+ {
124
+ kind: "writeHandler",
125
+ id: "retry",
126
+ label: i18nKey("jobs.detail.retry"),
127
+ handler: JobHandlers.retry,
128
+ payload: { map: { runId: "id" } },
129
+ visible: { field: "status", eq: "failed" },
130
+ style: "primary",
131
+ },
132
+ ],
133
+ description: "jobs.screen.job-run-detail.subtitle",
134
+ listScreenId: JOB_RUNS_SCREEN_ID,
135
+ access: systemAdminAccess,
136
+ };
137
+
138
+ export const eventConsumersScreen: ProjectionListScreenDefinition = {
139
+ id: EVENT_CONSUMERS_SCREEN_ID,
140
+ type: "projectionList",
141
+ query: JobQueries.consumers,
142
+ columns: [
143
+ { field: "name", label: i18nKey("jobs.consumers.col.name") },
144
+ {
145
+ field: "status",
146
+ label: i18nKey("jobs.consumers.col.status"),
147
+ renderer: { format: "enumOption", keyPrefix: CONSUMER_STATUS_OPTION_KEY_PREFIX },
148
+ },
149
+ { field: "lag", label: i18nKey("jobs.consumers.col.lag"), hideOnNarrow: true },
150
+ { field: "attempts", label: i18nKey("jobs.consumers.col.attempts"), hideOnNarrow: true },
151
+ { field: "lastError", label: i18nKey("jobs.consumers.col.lastError") },
152
+ ],
153
+ defaultSort: { field: "status", dir: "asc" },
154
+ facets: [
155
+ {
156
+ field: "status",
157
+ type: "select",
158
+ label: i18nKey("jobs.consumers.col.status"),
159
+ options: CONSUMER_STATUSES.map((status) => ({
160
+ value: status,
161
+ label: i18nKey(`${CONSUMER_STATUS_OPTION_KEY_PREFIX}${status}`),
162
+ })),
163
+ },
164
+ ],
165
+ rowActions: [
166
+ {
167
+ kind: "navigate",
168
+ id: "open",
169
+ label: i18nKey("jobs.runs.open"),
170
+ screen: EVENT_CONSUMER_DETAIL_SCREEN_ID,
171
+ entityId: "id",
172
+ rowClick: true,
173
+ },
174
+ ],
175
+ pagination: "infinite",
176
+ access: systemAdminAccess,
177
+ description: "jobs.screen.event-consumers.subtitle",
178
+ };
179
+
180
+ export const eventConsumerDetailScreen: ProjectionDetailScreenDefinition = {
181
+ id: EVENT_CONSUMER_DETAIL_SCREEN_ID,
182
+ type: "projectionDetail",
183
+ query: JobQueries.consumerDetails,
184
+ idParam: "consumerId",
185
+ fieldLabels: {
186
+ name: i18nKey("jobs.consumers.col.name"),
187
+ instanceId: i18nKey("jobs.consumers.field.instanceId"),
188
+ status: i18nKey("jobs.consumers.col.status"),
189
+ lag: i18nKey("jobs.consumers.col.lag"),
190
+ attempts: i18nKey("jobs.consumers.col.attempts"),
191
+ lastError: i18nKey("jobs.consumers.col.lastError"),
192
+ updatedAt: i18nKey("jobs.consumers.field.updatedAt"),
193
+ },
194
+ layout: {
195
+ sections: [
196
+ {
197
+ fields: [
198
+ "name",
199
+ {
200
+ field: "instanceId",
201
+ renderer: { format: "enumOption", keyPrefix: CONSUMER_INSTANCE_OPTION_KEY_PREFIX },
202
+ },
203
+ {
204
+ field: "status",
205
+ renderer: { format: "enumOption", keyPrefix: CONSUMER_STATUS_OPTION_KEY_PREFIX },
206
+ },
207
+ "lag",
208
+ "attempts",
209
+ { field: "updatedAt", renderer: { format: "timestamp" } },
210
+ ],
211
+ },
212
+ { fields: ["lastError"] },
213
+ ],
214
+ },
215
+ actions: [
216
+ {
217
+ kind: "writeHandler",
218
+ id: "restart",
219
+ icon: "refresh",
220
+ label: i18nKey("jobs.consumers.restart"),
221
+ handler: JobHandlers.consumerRestart,
222
+ payload: { map: { name: "name", instanceId: "instanceId" } },
223
+ visible: { field: "status", eq: "dead" },
224
+ style: "primary",
225
+ },
226
+ {
227
+ kind: "writeHandler",
228
+ id: "skip",
229
+ icon: "arrow-right",
230
+ label: i18nKey("jobs.consumers.skip"),
231
+ handler: JobHandlers.consumerSkip,
232
+ payload: { map: { name: "name", instanceId: "instanceId" } },
233
+ visible: { field: "status", eq: "dead" },
234
+ confirm: i18nKey("jobs.consumers.skip.confirm"),
235
+ style: "danger",
236
+ },
237
+ {
238
+ kind: "writeHandler",
239
+ id: "disable",
240
+ icon: "x",
241
+ label: i18nKey("jobs.consumers.disable"),
242
+ handler: JobHandlers.consumerDisable,
243
+ payload: { map: { name: "name", instanceId: "instanceId" } },
244
+ visible: { field: "status", ne: "disabled" },
245
+ style: "secondary",
246
+ },
247
+ {
248
+ kind: "writeHandler",
249
+ id: "enable",
250
+ icon: "check",
251
+ label: i18nKey("jobs.consumers.enable"),
252
+ handler: JobHandlers.consumerEnable,
253
+ payload: { map: { name: "name", instanceId: "instanceId" } },
254
+ visible: { field: "status", eq: "disabled" },
255
+ style: "primary",
256
+ },
257
+ ],
258
+ primaryAction: "restart",
259
+ description: "jobs.screen.event-consumer-detail.subtitle",
260
+ listScreenId: EVENT_CONSUMERS_SCREEN_ID,
261
+ access: systemAdminAccess,
262
+ };
@@ -5,6 +5,10 @@ export const JOBS_FEATURE = "jobs" as const;
5
5
  export const JobHandlers = {
6
6
  trigger: "jobs:write:trigger",
7
7
  retry: "jobs:write:retry",
8
+ consumerRestart: "jobs:write:consumer-restart",
9
+ consumerSkip: "jobs:write:consumer-skip",
10
+ consumerDisable: "jobs:write:consumer-disable",
11
+ consumerEnable: "jobs:write:consumer-enable",
8
12
  } as const;
9
13
 
10
14
  // Qualified query handler names (QN format: scope:type:name)
@@ -14,6 +18,8 @@ export const JobQueries = {
14
18
  catalog: "jobs:query:catalog",
15
19
  failures: "jobs:query:failures",
16
20
  tenantRuns: "jobs:query:tenant-runs",
21
+ consumers: "jobs:query:consumers",
22
+ consumerDetails: "jobs:query:consumer-details",
17
23
  } as const;
18
24
 
19
25
  // Error codes
@@ -24,6 +30,10 @@ export const JobErrors = {
24
30
  notManual: "job_not_manual",
25
31
  invalidPayload: "job_invalid_payload",
26
32
  payloadErased: "job_payload_erased",
33
+ consumerUnknown: "consumer_unknown",
34
+ consumerWrongStatus: "consumer_wrong_status",
35
+ consumerNothingToSkip: "consumer_nothing_to_skip",
36
+ consumerTransitionFailed: "consumer_transition_failed",
27
37
  } as const;
28
38
 
29
39
  /** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
@@ -31,3 +41,22 @@ export const JOB_RUNS_SCREEN_ID = "job-runs" as const;
31
41
 
32
42
  /** Run detail — route entityId is the run uuid. Nav: `jobs:screen:job-run-detail`. */
33
43
  export const JOB_RUN_DETAIL_SCREEN_ID = "job-run-detail" as const;
44
+
45
+ /** SystemAdmin event-consumer list. Nav: `jobs:screen:event-consumers`. */
46
+ export const EVENT_CONSUMERS_SCREEN_ID = "event-consumers" as const;
47
+
48
+ /** Consumer detail — route entityId is `<name>@<instanceId>`. */
49
+ export const EVENT_CONSUMER_DETAIL_SCREEN_ID = "event-consumer-detail" as const;
50
+
51
+ export const CONSUMER_ID_SEPARATOR = "@" as const;
52
+
53
+ export const CONSUMER_STATUSES = ["idle", "processing", "dead", "disabled", "never-run"] as const;
54
+ export type ConsumerStatus = (typeof CONSUMER_STATUSES)[number];
55
+
56
+ export function isConsumerStatus(value: string): value is ConsumerStatus {
57
+ return CONSUMER_STATUSES.some((status) => status === value);
58
+ }
59
+
60
+ export const CONSUMER_STATUS_OPTION_KEY_PREFIX = "jobs.consumers.status.";
61
+
62
+ export const CONSUMER_INSTANCE_OPTION_KEY_PREFIX = "jobs.consumers.instance.";
@@ -15,6 +15,47 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
15
15
  "jobs:entity:__action-form__:field:jobName": { en: "Job name" },
16
16
  "jobs:entity:__action-form__:field:payload": { en: "JSON payload" },
17
17
  "jobs:nav.jobRuns": { en: "Jobs" },
18
+ "screen:event-consumers.title": { en: "Event consumers" },
19
+ "screen:event-consumer-detail.title": { en: "Event consumer" },
20
+ "jobs:nav.eventConsumers": { en: "Event consumers" },
21
+ "jobs.screen.event-consumers.subtitle": {
22
+ en: "Background readers that process events after they are saved; find stuck ones and get them moving again.",
23
+ },
24
+ "jobs.screen.event-consumer-detail.subtitle": {
25
+ en: "State and last error of one event consumer, with the actions to restart, skip or pause it.",
26
+ },
27
+ "jobs.consumers.col.name": { en: "Consumer" },
28
+ "jobs.consumers.col.status": { en: "Status" },
29
+ "jobs.consumers.col.lag": { en: "Events behind" },
30
+ "jobs.consumers.col.attempts": { en: "Attempts" },
31
+ "jobs.consumers.col.lastError": { en: "Last error" },
32
+ "jobs.consumers.field.instanceId": { en: "Instance" },
33
+ "jobs.consumers.field.updatedAt": { en: "Last change" },
34
+ "jobs.consumers.status.idle": { en: "Up to date" },
35
+ "jobs.consumers.status.processing": { en: "Running" },
36
+ "jobs.consumers.status.dead": { en: "Stopped by error" },
37
+ "jobs.consumers.status.disabled": { en: "Paused" },
38
+ "jobs.consumers.status.never-run": { en: "Not started yet" },
39
+ "jobs.consumers.instance.__shared__": { en: "Shared" },
40
+ "jobs.consumers.restart": { en: "Restart" },
41
+ "jobs.consumers.skip": { en: "Skip event" },
42
+ "jobs.consumers.skip.confirm": {
43
+ en: "The event that keeps failing is skipped and never delivered to this consumer, so its data may stay incomplete. Skip only if the event can never succeed.",
44
+ },
45
+ "jobs.consumers.disable": { en: "Pause" },
46
+ "jobs.consumers.enable": { en: "Resume" },
47
+ "jobs.errors.consumerUnknown": {
48
+ en: "This consumer has not run yet or does not exist.",
49
+ },
50
+ "jobs.errors.consumerWrongStatus": {
51
+ en: "The consumer is not in a state where this action applies. Reload and check its status.",
52
+ },
53
+ "jobs.errors.consumerNothingToSkip": {
54
+ en: "There is no failing event to skip.",
55
+ },
56
+ "jobs.errors.consumerTransitionFailed": {
57
+ en: "The consumer could not be changed. Try again; if it keeps failing, check the server log.",
58
+ },
18
59
  "jobs.runs.open": { en: "Details" },
19
60
  "jobs.runs.filter.status": { en: "Status" },
20
61
  "jobs.runs.filter.completed": { en: "Completed" },
@@ -1,7 +1,7 @@
1
1
  // Client-safe ledger surface: QN constants, the pure recurring helpers
2
2
  // (projection + Soll/Ist merge), and the Zod payload schemas (for client-side
3
3
  // pre-validation before dispatch) — nothing else. The full `../ledger` entry
4
- // re-exports the feature/handlers/executor, which pull bun-db/postgres — a
4
+ // re-exports the feature/handlers/executor, which pull postgres-js — a
5
5
  // browser bundle that imports from there fails on Node builtins. A client screen
6
6
  // (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
7
7
  // from HERE, then dispatches via the renderer's useDispatcher.
@@ -69,7 +69,7 @@ export function createAddNoteHandler(
69
69
  let authorName: string | null = null;
70
70
  try {
71
71
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
72
- // Bun.SQL poisons the whole tx after any error inside it, even one that's
72
+ // postgres-js poisons the whole tx after any error inside it, even one that's
73
73
  // caught — so the lookup runs in a savepoint where available, otherwise directly
74
74
  // (pool statements are their own units).
75
75
  authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(), async (sp) => {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.385.0",
4
+ "type": "improvement",
5
+ "title": "patScopeOptionKey for translating token scope labels",
6
+ "detail": "The scope labels personal-access-tokens generates from `scopes[domain].label` are English only. `patScopeOptionKey(domain, \"read\" | \"write\")` returns their translation key, so an app feature can add the other locales with `r.translations`.",
7
+ "migration": "Apps that mount personal-access-tokens and set `requiredLocales` with a locale other than `en` translate each generated scope label (`read`, plus `write` where the domain offers it) under `patScopeOptionKey`."
8
+ },
2
9
  {
3
10
  "version": "0.372.0",
4
11
  "type": "fix",
@@ -8,6 +8,10 @@ const mintFieldKey = (field: string): string =>
8
8
  const scopeOptionKey = (grant: string): string =>
9
9
  fieldOptionLabelKey(PAT_FEATURE, ACTION_FORM_ENTITY, "scopes", grant);
10
10
 
11
+ // The generated scope labels are English only; apps translate them under this key.
12
+ export const patScopeOptionKey = (domain: string, level: "read" | "write"): string =>
13
+ scopeOptionKey(`${domain}:${level}`);
14
+
11
15
  type LocalizedString = { readonly en: string };
12
16
 
13
17
  export const PAT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
@@ -8,6 +8,7 @@ export {
8
8
  } from "./feature.ts";
9
9
  export type { CreatePatOptions, PatMfaVerifyResult } from "./handlers/create.write.ts";
10
10
  export { hashPatToken, mintPatToken } from "./hash.ts";
11
+ export { patScopeOptionKey } from "./i18n.ts";
11
12
  export {
12
13
  PAT_REVOKED_AGGREGATE_TYPE,
13
14
  PAT_REVOKED_EVENT_QN,
@@ -3,14 +3,11 @@ import { SECRETS_ERROR_KEYS } from "./web/constants.ts";
3
3
  export const SECRETS_I18N = {
4
4
  [SECRETS_ERROR_KEYS.unknownKey]: {
5
5
  en: "Unknown secret key.",
6
- de: "Unbekannter Geheimnis-Schlüssel.",
7
6
  },
8
7
  [SECRETS_ERROR_KEYS.writeDenied]: {
9
8
  en: "You are not allowed to change this secret.",
10
- de: "Dieses Geheimnis darf nicht geändert werden.",
11
9
  },
12
10
  [SECRETS_ERROR_KEYS.invalidValue]: {
13
11
  en: "This value is not valid for this secret.",
14
- de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
15
12
  },
16
13
  } as const;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "Session lists show the device",
6
+ "detail": "The new subpath @cosmicdrift/kumiko-bundled-features/sessions/web exports sessionsClient(). Apps must add sessionsClient() to their client features to get the device column (browser and OS parsed from the user agent) and the translated \"This session\" cell; without it the screens fall back to the raw columns. The IP display is unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.383.0",
4
10
  "type": "improvement",
@@ -1,4 +1,6 @@
1
- export const SESSIONS_FEATURE = "sessions" as const;
1
+ import { SESSIONS_FEATURE } from "./web/constants.ts";
2
+
3
+ export { SESSIONS_FEATURE };
2
4
  export const USER_SESSION_ENTITY_NAME = "user-session" as const;
3
5
 
4
6
  // Qualified write handler names — entity prefix is "userSession", snake_case
@@ -3,6 +3,7 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
3
3
  import * as z from "zod";
4
4
  import { decryptStoredPii } from "../../shared/index.ts";
5
5
  import { userSessionTable } from "../schema/user-session.ts";
6
+ import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
6
7
 
7
8
  // Admin single-session inspector — mirrors list.query's decrypt handling for
8
9
  // the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
@@ -14,6 +15,7 @@ const sessionRowSchema = z.object({
14
15
  revokedAt: z.unknown(),
15
16
  ip: z.string().nullable(),
16
17
  userAgent: z.string().nullable(),
18
+ device: parsedDeviceSchema,
17
19
  });
18
20
 
19
21
  export const createDetailQuery = (adminRoles: readonly string[] = access.admin) =>
@@ -37,6 +39,9 @@ export const createDetailQuery = (adminRoles: readonly string[] = access.admin)
37
39
  userAgent: string | null;
38
40
  }>(ctx.db, userSessionTable, { id: query.payload.id });
39
41
  if (!row) return null;
42
+ const userAgent = row.userAgent
43
+ ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
44
+ : row.userAgent;
40
45
  return {
41
46
  id: row.id,
42
47
  userId: row.userId,
@@ -44,9 +49,8 @@ export const createDetailQuery = (adminRoles: readonly string[] = access.admin)
44
49
  expiresAt: row.expiresAt,
45
50
  revokedAt: row.revokedAt,
46
51
  ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
47
- userAgent: row.userAgent
48
- ? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
49
- : row.userAgent,
52
+ userAgent,
53
+ device: parseUserAgent(userAgent),
50
54
  };
51
55
  },
52
56
  });
@@ -8,6 +8,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
8
8
  import * as z from "zod";
9
9
  import { decryptStoredPii } from "../../shared/index.ts";
10
10
  import { userSessionTable } from "../schema/user-session.ts";
11
+ import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
11
12
 
12
13
  // `sort` arrives raw from the client's query string. selectMany's orderBy
13
14
  // has no column-existence check — an unrecognised field just gets
@@ -47,6 +48,7 @@ export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
47
48
  revokedAt: z.unknown(),
48
49
  ip: z.string().nullable(),
49
50
  userAgent: z.string().nullable(),
51
+ device: parsedDeviceSchema,
50
52
  }),
51
53
  ),
52
54
  nextCursor: z.string().nullable(),
@@ -79,17 +81,21 @@ export const createListQuery = (adminRoles: readonly string[] = access.admin) =>
79
81
  ...(query.payload.limit !== undefined && { limit: query.payload.limit }),
80
82
  });
81
83
  const decryptedRows = await Promise.all(
82
- rows.map(async (r) => ({
83
- id: r.id,
84
- userId: r.userId,
85
- createdAt: r.createdAt,
86
- expiresAt: r.expiresAt,
87
- revokedAt: r.revokedAt,
88
- ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
89
- userAgent: r.userAgent
84
+ rows.map(async (r) => {
85
+ const userAgent = r.userAgent
90
86
  ? await decryptStoredPii(r.userAgent, "userAgent", "sessions:list")
91
- : r.userAgent,
92
- })),
87
+ : r.userAgent;
88
+ return {
89
+ id: r.id,
90
+ userId: r.userId,
91
+ createdAt: r.createdAt,
92
+ expiresAt: r.expiresAt,
93
+ revokedAt: r.revokedAt,
94
+ ip: r.ip ? await decryptStoredPii(r.ip, "ip", "sessions:list") : r.ip,
95
+ userAgent,
96
+ device: parseUserAgent(userAgent),
97
+ };
98
+ }),
93
99
  );
94
100
  return { rows: decryptedRows, nextCursor: null };
95
101
  },