@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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.
- package/AGENTS.md +3 -0
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples.md +1 -0
- package/agents/widgets.md +1 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +2 -3
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-extra-routes.js +3 -0
- package/dist/api/server-routes.d.ts +1 -1
- package/dist/api/server-routes.js +8 -2
- package/dist/api/server-wiring.js +2 -0
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +11 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/db/aggregate-sql.d.ts +2 -0
- package/dist/db/aggregate-sql.js +10 -0
- package/dist/db/closed-connection.js +12 -19
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/driver-row-coercion.js +4 -4
- package/dist/db/driver-value-preparation.js +6 -4
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.js +36 -5
- package/dist/db/event-store-executor-read-filter.js +6 -3
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +0 -3
- package/dist/db/event-store-executor-write-steps.js +0 -5
- package/dist/db/event-store-executor-write.js +15 -15
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +3 -3
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +14 -0
- package/dist/db/query.js +68 -43
- package/dist/db/reference-data.js +14 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +11 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
- package/dist/engine/boot-validator/screens-lists.js +12 -2
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +9 -1
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +4 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
- package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +5 -1
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +4 -0
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +3 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +7 -4
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/create-file-ref-from-bytes.js +3 -0
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +29 -2
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +4 -4
- package/dist/i18n/required-surface-keys.js +7 -2
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-scope.js +6 -6
- package/dist/jobs/job-runner.js +3 -0
- package/dist/observability/escape-hatch-report.js +9 -2
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-shared.js +4 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
- package/dist/pipeline/handler-context-dispatch.js +13 -1
- package/dist/pipeline/handler-context-events.d.ts +2 -4
- package/dist/pipeline/handler-context-events.js +6 -4
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/baseline-command.js +1 -1
- package/dist/schema-cli/status-command.js +1 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +22 -76
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +245 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
package/AGENTS.md
CHANGED
|
@@ -213,6 +213,9 @@ produces code that compiles and then fails in production.
|
|
|
213
213
|
(`kind` is `sum`, `avg`, `count`, `min` or `max`) and the list answers with
|
|
214
214
|
`{ groups: [{ key, label?, count, measures }] }` over the same filters,
|
|
215
215
|
search and ownership, at most 200 groups (`truncated: true` beyond that).
|
|
216
|
+
A money measure answers per currency, `[{ currency, value }]` with `value`
|
|
217
|
+
in minor units, never one sum across currencies. `bigInt` measures come
|
|
218
|
+
back as JS numbers and are exact only up to 2^53.
|
|
216
219
|
Custom handlers call `executor.aggregate(...)` or `answerListAggregate`.
|
|
217
220
|
- Never change an event schema in place once it has been emitted. Version it
|
|
218
221
|
(see `agents/examples/event-sourcing.md`).
|
|
@@ -107,6 +107,17 @@ describe("golden path: ticket", () => {
|
|
|
107
107
|
expect(list.rows.map((row) => row.id)).toEqual([id]);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("closing an already closed ticket is rejected with 422", async () => {
|
|
111
|
+
const id = await openTicket("Duplicate close");
|
|
112
|
+
const close = { id, resolution: "Fixed" };
|
|
113
|
+
await stack.http.writeOk("helpdesk:write:ticket:close", close, admin);
|
|
114
|
+
|
|
115
|
+
const again = await stack.http.writeErr("helpdesk:write:ticket:close", close, admin);
|
|
116
|
+
|
|
117
|
+
expect(again.httpStatus).toBe(422);
|
|
118
|
+
expect(again.details).toEqual({ reason: "ticket_already_closed" });
|
|
119
|
+
});
|
|
120
|
+
|
|
110
121
|
test("only an Admin may close a ticket", async () => {
|
|
111
122
|
const id = await openTicket("Slow wifi");
|
|
112
123
|
|
|
@@ -144,7 +155,7 @@ import {
|
|
|
144
155
|
emitEvent,
|
|
145
156
|
setFields,
|
|
146
157
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
147
|
-
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
158
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
148
159
|
import { z } from "zod";
|
|
149
160
|
|
|
150
161
|
export const ticketEntity = createEntity({
|
|
@@ -180,6 +191,7 @@ export const helpdeskFeature = defineFeature("helpdesk", (r) => {
|
|
|
180
191
|
if (!ticket) {
|
|
181
192
|
return failNotFound("ticket", event.payload.id);
|
|
182
193
|
}
|
|
194
|
+
if (ticket["isClosed"]) return failUnprocessable("ticket_already_closed");
|
|
183
195
|
await emitEvent(ctx, ticketClosed, {
|
|
184
196
|
aggregateId: event.payload.id,
|
|
185
197
|
aggregateType: "ticket",
|
|
@@ -35,6 +35,10 @@ back.
|
|
|
35
35
|
- **`r.hook({ type: "postDelete", target: { allOf: entityRef }, handler })`** — fires after commit.
|
|
36
36
|
External side effects (logs, notifications) belong here because
|
|
37
37
|
failures cannot roll the delete back.
|
|
38
|
+
- **`ctx.scheduleAfterRollback(fn)`** — the counterpart of `scheduleAfterCommit`: runs `fn` when
|
|
39
|
+
the handler transaction (or the enclosing savepoint of a nested `ctx.write`) does not commit.
|
|
40
|
+
Use it to give back something reserved outside the transaction, like the slot in
|
|
41
|
+
`article:reserve-slot`. A failed COMMIT triggers it too, so write the callback idempotent.
|
|
38
42
|
|
|
39
43
|
## When to reach for it
|
|
40
44
|
|
|
@@ -71,7 +75,7 @@ import {
|
|
|
71
75
|
type TestStack,
|
|
72
76
|
unsafeCreateEntityTable,
|
|
73
77
|
} from "@cosmicdrift/kumiko-testing/stack";
|
|
74
|
-
import { articleEntity, articlesFeature, hookLog } from "../feature";
|
|
78
|
+
import { articleEntity, articlesFeature, hookLog, reservedSlots } from "../feature";
|
|
75
79
|
|
|
76
80
|
let stack: TestStack;
|
|
77
81
|
|
|
@@ -90,6 +94,7 @@ afterAll(async () => {
|
|
|
90
94
|
beforeEach(() => {
|
|
91
95
|
stack.events.reset();
|
|
92
96
|
hookLog.length = 0;
|
|
97
|
+
reservedSlots.clear();
|
|
93
98
|
});
|
|
94
99
|
|
|
95
100
|
describe("validation hook: single handler", () => {
|
|
@@ -256,6 +261,29 @@ describe("preDelete + postDelete entity hooks", () => {
|
|
|
256
261
|
expect(hookLog.map((e) => e.type)).toEqual(["deleted"]);
|
|
257
262
|
});
|
|
258
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("scheduleAfterRollback", () => {
|
|
266
|
+
test("keeps the outside-transaction reservation when the write commits", async () => {
|
|
267
|
+
await stack.http.writeOk(
|
|
268
|
+
"blog:write:article:reserve-slot",
|
|
269
|
+
{ slot: "kept", reject: false },
|
|
270
|
+
editor,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(reservedSlots.has("kept")).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("gives the reservation back when the write rolls back", async () => {
|
|
277
|
+
const error = await stack.http.writeErr(
|
|
278
|
+
"blog:write:article:reserve-slot",
|
|
279
|
+
{ slot: "released", reject: true },
|
|
280
|
+
editor,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expectErrorIncludes(error, "slot_rejected");
|
|
284
|
+
expect(reservedSlots.has("released")).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
259
287
|
```
|
|
260
288
|
|
|
261
289
|
### src/feature.ts
|
|
@@ -276,7 +304,12 @@ import {
|
|
|
276
304
|
defineFeature,
|
|
277
305
|
type SaveContext,
|
|
278
306
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
279
|
-
import {
|
|
307
|
+
import {
|
|
308
|
+
ConflictError,
|
|
309
|
+
UnprocessableError,
|
|
310
|
+
writeFailure,
|
|
311
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
312
|
+
import * as z from "zod";
|
|
280
313
|
|
|
281
314
|
export const articleEntity = createEntity({
|
|
282
315
|
table: "read_sample_articles",
|
|
@@ -298,6 +331,9 @@ export const articleEntity = createEntity({
|
|
|
298
331
|
// Collect hook events for testing
|
|
299
332
|
export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
|
|
300
333
|
|
|
334
|
+
// Stands in for a quota held in an external system, outside the handler transaction.
|
|
335
|
+
export const reservedSlots = new Set<string>();
|
|
336
|
+
|
|
301
337
|
const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
|
|
302
338
|
|
|
303
339
|
export const articlesFeature = defineFeature("blog", (r) => {
|
|
@@ -311,6 +347,23 @@ export const articlesFeature = defineFeature("blog", (r) => {
|
|
|
311
347
|
);
|
|
312
348
|
r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
|
|
313
349
|
|
|
350
|
+
// --- scheduleAfterRollback: give back a reservation made outside the transaction when it does
|
|
351
|
+
// not commit. The callback must be idempotent: a failed COMMIT also triggers it.
|
|
352
|
+
r.writeHandler({
|
|
353
|
+
name: "article:reserve-slot",
|
|
354
|
+
schema: z.object({ slot: z.string(), reject: z.boolean() }),
|
|
355
|
+
access: editorWrite.access,
|
|
356
|
+
handler: async (event, ctx) => {
|
|
357
|
+
const { slot, reject } = event.payload;
|
|
358
|
+
reservedSlots.add(slot);
|
|
359
|
+
ctx.scheduleAfterRollback?.(async () => {
|
|
360
|
+
reservedSlots.delete(slot);
|
|
361
|
+
});
|
|
362
|
+
if (reject) return writeFailure(new UnprocessableError("slot_rejected"));
|
|
363
|
+
return { isSuccess: true as const, data: {} };
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
314
367
|
// --- Validation hook on create: reject banned words + length ---
|
|
315
368
|
r.hook({
|
|
316
369
|
type: "validation",
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/list-table-options. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# list-table-options
|
|
4
|
+
|
|
5
|
+
Opt-in table behaviour of a list: remembered search/sort/filters, one reset button, column picker with hidden columns, row density, server-side groups with sums, a totals row and a number range facet.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# List table options
|
|
10
|
+
|
|
11
|
+
Opt-in table behaviour for entity, projection and related lists. A list without
|
|
12
|
+
the `table` block renders and behaves as before.
|
|
13
|
+
|
|
14
|
+
## What it shows
|
|
15
|
+
|
|
16
|
+
- `table.rememberState`: search, sort, page size, facets and date range survive
|
|
17
|
+
leaving and re-entering the list within the browser session.
|
|
18
|
+
- `table.resetAll`: one button resets everything that differs from the defaults.
|
|
19
|
+
- `table.columnPicker`: the view menu lets the user hide and reorder columns;
|
|
20
|
+
`hidden: true` on a column starts it hidden.
|
|
21
|
+
- `table.density`: row density default and an optional user choice.
|
|
22
|
+
- `table.pinning`: the user pins columns left or right in the view menu;
|
|
23
|
+
`pinned: "left"` on a column starts it pinned.
|
|
24
|
+
- `table.resizing`: columns can be resized (table-layout fixed); `width` and
|
|
25
|
+
`minWidth` on a column are start values in px.
|
|
26
|
+
- `table.keyboardNavigation`: rows are one tab stop, arrow keys move between rows,
|
|
27
|
+
Space selects the focused row.
|
|
28
|
+
- `table.export`: an Export button downloads the whole filtered list as an XLSX
|
|
29
|
+
file; `maxRows` lowers the default limit of 10000 rows.
|
|
30
|
+
Reference columns export their label and timestamps use the browser time zone.
|
|
31
|
+
- `bulkActions`: row checkboxes and a selection bar. "Delete" runs the delete
|
|
32
|
+
row action once per selected row; done tasks are skipped.
|
|
33
|
+
- `table.grouping`: the server groups the list by status; each header shows the
|
|
34
|
+
group's count and estimate sum, "done" starts collapsed and the view menu
|
|
35
|
+
offers grouping by priority or none.
|
|
36
|
+
- `table.totals`: a footer row with the estimate sum over the filtered list.
|
|
37
|
+
- `facets.estimateHours: { display: "range" }`: from/to inputs for a number field.
|
|
38
|
+
- The same block on a `projectionList`, and `task:workload`, a hand-written list
|
|
39
|
+
query that keeps grouping and totals by accepting `aggregate`
|
|
40
|
+
(`entityListSchema`) and answering it with `answerListAggregate`.
|
|
41
|
+
|
|
42
|
+
## Source
|
|
43
|
+
|
|
44
|
+
Feature entry point: `src/feature.ts`.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd samples/recipes/list-table-options
|
|
50
|
+
bun test
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The test boots the feature through the boot validator and checks that a
|
|
54
|
+
misspelled key, a hidden column without the picker, an unknown density, a pinned column
|
|
55
|
+
without `pinning`, a width without `resizing` and a bulk action on a navigate
|
|
56
|
+
row action are rejected.
|
|
57
|
+
`workload.integration.test.ts` runs the hand-written query over HTTP and checks
|
|
58
|
+
the grouped sums and the filtered total.
|
|
59
|
+
|
|
60
|
+
## Source files
|
|
61
|
+
|
|
62
|
+
### src/__tests__/feature.test.ts
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { describe, expect, test } from "bun:test";
|
|
66
|
+
import {
|
|
67
|
+
createRegistry,
|
|
68
|
+
defineFeature,
|
|
69
|
+
validateBoot as validateBootRaw,
|
|
70
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
71
|
+
import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
72
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
73
|
+
import { createTaskBoardFeature, openAccess, taskEntity } from "../feature";
|
|
74
|
+
|
|
75
|
+
// The boot validator is the net for blocks the type system never sees (JS, generated features).
|
|
76
|
+
function featureWithBlock(block: object) {
|
|
77
|
+
return createTaskBoardFeature(block as ListTableOptions);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
81
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("list-table-options recipe", () => {
|
|
85
|
+
test("the full table block boots and reaches the registered screen", () => {
|
|
86
|
+
const feature = createTaskBoardFeature();
|
|
87
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
88
|
+
const screen = createRegistry([feature]).getScreen("task-board:screen:task-list");
|
|
89
|
+
expect(screen?.type === "entityList" ? screen.table : undefined).toEqual({
|
|
90
|
+
rememberState: true,
|
|
91
|
+
resetAll: true,
|
|
92
|
+
columnPicker: true,
|
|
93
|
+
density: { default: "comfortable", userChoice: true },
|
|
94
|
+
pinning: true,
|
|
95
|
+
resizing: true,
|
|
96
|
+
keyboardNavigation: true,
|
|
97
|
+
export: { xlsx: true, maxRows: 5000 },
|
|
98
|
+
grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
|
|
99
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the bulk action resolves to the delete row action", () => {
|
|
104
|
+
const screen = createRegistry([createTaskBoardFeature()]).getScreen(
|
|
105
|
+
"task-board:screen:task-list",
|
|
106
|
+
);
|
|
107
|
+
expect(screen?.type === "entityList" ? screen.bulkActions : undefined).toEqual([
|
|
108
|
+
{ id: "delete", rowAction: "delete" },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a bulk action that points at a navigate row action is rejected", () => {
|
|
113
|
+
const feature = defineFeature("bulk-on-navigate", (r) => {
|
|
114
|
+
r.crud("task", taskEntity, { write: openAccess, read: openAccess });
|
|
115
|
+
r.screen({
|
|
116
|
+
id: "task-list",
|
|
117
|
+
type: "entityList",
|
|
118
|
+
entity: "task",
|
|
119
|
+
columns: ["title"],
|
|
120
|
+
rowActions: [{ kind: "navigate", id: "open", label: "Open", screen: "task-list" }],
|
|
121
|
+
bulkActions: [{ id: "open", rowAction: "open" }],
|
|
122
|
+
access: openAccess.access,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
126
|
+
/bulk action "open" points at rowAction "open" of kind "navigate"/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a pinned column needs table.pinning", () => {
|
|
131
|
+
expect(() => validateBoot([featureWithBlock({ columnPicker: true, resizing: true })])).toThrow(
|
|
132
|
+
/column "title" is pinned but table\.pinning is not enabled/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("width and minWidth need table.resizing", () => {
|
|
137
|
+
expect(() => validateBoot([featureWithBlock({ columnPicker: true, pinning: true })])).toThrow(
|
|
138
|
+
/column "title" sets width but table\.resizing is not enabled/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a misspelled table key is rejected with the allowed keys", () => {
|
|
143
|
+
expect(() => validateBoot([featureWithBlock({ rememberStatee: true })])).toThrow(
|
|
144
|
+
/unknown key\(s\) "rememberStatee"\. Allowed keys: rememberState, resetAll, columnPicker, density, pinning, resizing, keyboardNavigation, export/,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a hidden column needs the column picker", () => {
|
|
149
|
+
expect(() => validateBoot([featureWithBlock({ rememberState: true, resetAll: true })])).toThrow(
|
|
150
|
+
/column "estimateHours" is hidden but table\.columnPicker is not enabled/,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a density outside the union is rejected", () => {
|
|
155
|
+
expect(() =>
|
|
156
|
+
validateBoot([featureWithBlock({ columnPicker: true, density: { default: "tight" } })]),
|
|
157
|
+
).toThrow(/table\.density\.default is "tight"/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an export row limit above the hard cap is rejected", () => {
|
|
161
|
+
expect(() =>
|
|
162
|
+
validateBoot([
|
|
163
|
+
featureWithBlock({ columnPicker: true, export: { xlsx: true, maxRows: 100001 } }),
|
|
164
|
+
]),
|
|
165
|
+
).toThrow(/table\.export\.maxRows is 100001 .* at most 100000/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### src/__tests__/workload.integration.test.ts
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
174
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
175
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
176
|
+
import { createTaskBoardFeature } from "../feature";
|
|
177
|
+
|
|
178
|
+
type ListAggregateResult = {
|
|
179
|
+
readonly groups: readonly { key: unknown; count: number; measures: readonly unknown[] }[];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
let stack: TestStack;
|
|
183
|
+
const user = createTestUser({ roles: ["User"], tenantId: testTenantId(1) });
|
|
184
|
+
|
|
185
|
+
const TASKS = [
|
|
186
|
+
{ title: "Quarterly VAT filing", assignee: "Ines", estimateHours: 6 },
|
|
187
|
+
{ title: "Prepare audit", assignee: "Ines", estimateHours: 24 },
|
|
188
|
+
{ title: "Plan offsite", assignee: "Mara", estimateHours: 7 },
|
|
189
|
+
] as const;
|
|
190
|
+
|
|
191
|
+
beforeAll(async () => {
|
|
192
|
+
stack = await setupTestStack({ features: [createTaskBoardFeature()], systemHooks: [] });
|
|
193
|
+
for (const task of TASKS) {
|
|
194
|
+
await stack.http.writeOk("task-board:write:task:create", task, user);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterAll(async () => {
|
|
199
|
+
await stack.cleanup();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("hand-written list query with aggregate", () => {
|
|
203
|
+
test("groups by assignee with the server count and estimate sum", async () => {
|
|
204
|
+
const result = await stack.http.queryOk<ListAggregateResult>(
|
|
205
|
+
"task-board:query:task:workload",
|
|
206
|
+
{ aggregate: { groupBy: "assignee", measures: [{ field: "estimateHours", kind: "sum" }] } },
|
|
207
|
+
user,
|
|
208
|
+
);
|
|
209
|
+
expect(result.groups.map(({ key, count, measures }) => ({ key, count, measures }))).toEqual([
|
|
210
|
+
{ key: "Ines", count: 2, measures: [30] },
|
|
211
|
+
{ key: "Mara", count: 1, measures: [7] },
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("the totals answer follows the list filter", async () => {
|
|
216
|
+
const result = await stack.http.queryOk<ListAggregateResult>(
|
|
217
|
+
"task-board:query:task:workload",
|
|
218
|
+
{
|
|
219
|
+
filters: [{ field: "estimateHours", op: "lte", value: 7 }],
|
|
220
|
+
aggregate: { measures: [{ field: "estimateHours", kind: "sum" }] },
|
|
221
|
+
},
|
|
222
|
+
user,
|
|
223
|
+
);
|
|
224
|
+
expect(result.groups.map((group) => group.measures)).toEqual([[13]]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("without aggregate the same query lists rows", async () => {
|
|
228
|
+
const list = await stack.http.queryOk<{ rows: { title: string }[] }>(
|
|
229
|
+
"task-board:query:task:workload",
|
|
230
|
+
{ sort: "title" },
|
|
231
|
+
user,
|
|
232
|
+
);
|
|
233
|
+
expect(list.rows.map((row) => row.title)).toEqual([
|
|
234
|
+
"Plan offsite",
|
|
235
|
+
"Prepare audit",
|
|
236
|
+
"Quarterly VAT filing",
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### src/feature.ts
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import {
|
|
246
|
+
answerListAggregate,
|
|
247
|
+
buildEntityTable,
|
|
248
|
+
createEventStoreExecutor,
|
|
249
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
250
|
+
import {
|
|
251
|
+
createDateField,
|
|
252
|
+
createEntity,
|
|
253
|
+
createNumberField,
|
|
254
|
+
createSelectField,
|
|
255
|
+
createTextField,
|
|
256
|
+
defineFeature,
|
|
257
|
+
entityListSchema,
|
|
258
|
+
type FeatureDefinition,
|
|
259
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
260
|
+
import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
261
|
+
import { z } from "zod";
|
|
262
|
+
|
|
263
|
+
export const taskEntity = createEntity({
|
|
264
|
+
table: "read_list_table_tasks",
|
|
265
|
+
fields: {
|
|
266
|
+
title: createTextField({
|
|
267
|
+
personal: false,
|
|
268
|
+
reason: "is_business_data",
|
|
269
|
+
required: true,
|
|
270
|
+
searchable: true,
|
|
271
|
+
sortable: true,
|
|
272
|
+
}),
|
|
273
|
+
status: createSelectField({
|
|
274
|
+
options: ["open", "inProgress", "blocked", "done"] as const,
|
|
275
|
+
default: "open",
|
|
276
|
+
sortable: true,
|
|
277
|
+
filterable: true,
|
|
278
|
+
}),
|
|
279
|
+
priority: createSelectField({
|
|
280
|
+
options: ["low", "medium", "high"] as const,
|
|
281
|
+
default: "medium",
|
|
282
|
+
sortable: true,
|
|
283
|
+
filterable: true,
|
|
284
|
+
}),
|
|
285
|
+
dueDate: createDateField({ sortable: true }),
|
|
286
|
+
assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
|
|
287
|
+
estimateHours: createNumberField({ sortable: true, filterable: true }),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
export const openAccess = {
|
|
292
|
+
access: {
|
|
293
|
+
openToAll: { reason: "recipe: any signed-in user manages the tasks; no per-user ownership" },
|
|
294
|
+
},
|
|
295
|
+
} as const;
|
|
296
|
+
|
|
297
|
+
// Overridable so a test can feed the boot validator a block that must be rejected.
|
|
298
|
+
export function createTaskBoardFeature(
|
|
299
|
+
taskTable: ListTableOptions = {
|
|
300
|
+
rememberState: true,
|
|
301
|
+
resetAll: true,
|
|
302
|
+
columnPicker: true,
|
|
303
|
+
density: { default: "comfortable", userChoice: true },
|
|
304
|
+
pinning: true,
|
|
305
|
+
resizing: true,
|
|
306
|
+
keyboardNavigation: true,
|
|
307
|
+
export: { xlsx: true, maxRows: 5000 },
|
|
308
|
+
grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
|
|
309
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
310
|
+
},
|
|
311
|
+
): FeatureDefinition {
|
|
312
|
+
return defineFeature("task-board", (r) => {
|
|
313
|
+
r.crud("task", taskEntity, { write: openAccess, read: openAccess });
|
|
314
|
+
|
|
315
|
+
r.screen({
|
|
316
|
+
id: "task-list",
|
|
317
|
+
type: "entityList",
|
|
318
|
+
entity: "task",
|
|
319
|
+
columns: [
|
|
320
|
+
// Starts pinned left; width and minWidth are start values for resizing.
|
|
321
|
+
{ field: "title", pinned: "left", width: 280, minWidth: 160 },
|
|
322
|
+
"status",
|
|
323
|
+
"priority",
|
|
324
|
+
{ field: "dueDate", hideOnNarrow: true },
|
|
325
|
+
{ field: "assignee", hideOnNarrow: true },
|
|
326
|
+
// Starts hidden; the user can add it back in the view menu.
|
|
327
|
+
{ field: "estimateHours", hidden: true, hideOnNarrow: true },
|
|
328
|
+
],
|
|
329
|
+
facets: { status: { display: "chips" }, priority: {}, estimateHours: { display: "range" } },
|
|
330
|
+
defaultSort: { field: "dueDate", dir: "asc" },
|
|
331
|
+
searchable: true,
|
|
332
|
+
table: taskTable,
|
|
333
|
+
rowActions: [
|
|
334
|
+
{ kind: "navigate", id: "open", label: "Open", screen: "task-edit", rowClick: true },
|
|
335
|
+
{
|
|
336
|
+
kind: "writeHandler",
|
|
337
|
+
id: "delete",
|
|
338
|
+
label: "Delete",
|
|
339
|
+
handler: "task-board:write:task:delete",
|
|
340
|
+
style: "danger",
|
|
341
|
+
// Done tasks are skipped by the bulk run and counted as skipped.
|
|
342
|
+
visible: { field: "status", ne: "done" },
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
// Row checkboxes and a selection bar; each selected row gets its own delete write.
|
|
346
|
+
bulkActions: [{ id: "delete", rowAction: "delete" }],
|
|
347
|
+
access: openAccess.access,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
r.screen({
|
|
351
|
+
id: "task-edit",
|
|
352
|
+
type: "entityEdit",
|
|
353
|
+
entity: "task",
|
|
354
|
+
dormant: true,
|
|
355
|
+
listScreenId: "task-list",
|
|
356
|
+
layout: {
|
|
357
|
+
sections: [
|
|
358
|
+
{
|
|
359
|
+
columns: 2,
|
|
360
|
+
fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// The same block works on a list over any query.
|
|
367
|
+
r.queryHandler({
|
|
368
|
+
name: "task:overdue",
|
|
369
|
+
schema: z.object({}),
|
|
370
|
+
handler: async () => ({
|
|
371
|
+
rows: [{ id: "t1", title: "Quarterly VAT filing", assignee: "Ines", daysLate: 3 }],
|
|
372
|
+
nextCursor: null,
|
|
373
|
+
}),
|
|
374
|
+
access: openAccess.access,
|
|
375
|
+
outputSchema: z.object({
|
|
376
|
+
rows: z.array(
|
|
377
|
+
z.object({
|
|
378
|
+
id: z.string(),
|
|
379
|
+
title: z.string(),
|
|
380
|
+
assignee: z.string(),
|
|
381
|
+
daysLate: z.number(),
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
nextCursor: z.string().nullable(),
|
|
385
|
+
}),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
r.screen({
|
|
389
|
+
id: "task-overdue",
|
|
390
|
+
type: "projectionList",
|
|
391
|
+
query: "task-board:query:task:overdue",
|
|
392
|
+
columns: ["title", "assignee", "daysLate"],
|
|
393
|
+
table: { rememberState: true, density: { userChoice: true } },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// A hand-written list query keeps grouping and totals when it accepts
|
|
397
|
+
// `aggregate` (entityListSchema carries it) and answers it on the server.
|
|
398
|
+
const taskExecutor = createEventStoreExecutor(
|
|
399
|
+
buildEntityTable("task", taskEntity),
|
|
400
|
+
taskEntity,
|
|
401
|
+
{
|
|
402
|
+
entityName: "task",
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
r.queryHandler({
|
|
406
|
+
name: "task:workload",
|
|
407
|
+
schema: entityListSchema,
|
|
408
|
+
handler: async (query, ctx) => {
|
|
409
|
+
const parentVisibility = { entities: ctx.registry.getAllEntities() };
|
|
410
|
+
const listPayload = query.payload;
|
|
411
|
+
if (listPayload.aggregate !== undefined) {
|
|
412
|
+
return answerListAggregate(
|
|
413
|
+
taskExecutor,
|
|
414
|
+
{ ...listPayload, aggregate: listPayload.aggregate },
|
|
415
|
+
query.user,
|
|
416
|
+
ctx.db,
|
|
417
|
+
{ parentVisibility },
|
|
418
|
+
{
|
|
419
|
+
entity: taskEntity,
|
|
420
|
+
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
421
|
+
viewer: { user: query.user, parentVisibility },
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return taskExecutor.list(listPayload, query.user, ctx.db, { parentVisibility });
|
|
426
|
+
},
|
|
427
|
+
access: openAccess.access,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
r.screen({
|
|
431
|
+
id: "task-workload",
|
|
432
|
+
type: "projectionList",
|
|
433
|
+
query: "task-board:query:task:workload",
|
|
434
|
+
columns: ["title", "assignee", "status", "estimateHours"],
|
|
435
|
+
defaultSort: { field: "title", dir: "asc" },
|
|
436
|
+
table: {
|
|
437
|
+
grouping: { field: "assignee" },
|
|
438
|
+
totals: [{ field: "estimateHours", kind: "sum" }],
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
r.nav({ id: "tasks", label: "task-board:nav.tasks", icon: "list", order: 10 });
|
|
443
|
+
r.nav({
|
|
444
|
+
id: "task-list",
|
|
445
|
+
label: "task-board:nav.list",
|
|
446
|
+
parent: "task-board:nav:tasks",
|
|
447
|
+
screen: "task-board:screen:task-list",
|
|
448
|
+
order: 10,
|
|
449
|
+
});
|
|
450
|
+
r.nav({
|
|
451
|
+
id: "task-overdue",
|
|
452
|
+
label: "task-board:nav.overdue",
|
|
453
|
+
parent: "task-board:nav:tasks",
|
|
454
|
+
screen: "task-board:screen:task-overdue",
|
|
455
|
+
order: 20,
|
|
456
|
+
});
|
|
457
|
+
r.nav({
|
|
458
|
+
id: "task-workload",
|
|
459
|
+
label: "task-board:nav.workload",
|
|
460
|
+
parent: "task-board:nav:tasks",
|
|
461
|
+
screen: "task-board:screen:task-workload",
|
|
462
|
+
order: 30,
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
```
|
|
@@ -42,6 +42,7 @@ bun test
|
|
|
42
42
|
// and its text contains exactly the inspections of the requested period.
|
|
43
43
|
|
|
44
44
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
45
|
+
import { MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
46
|
import {
|
|
46
47
|
createFilesFeature,
|
|
47
48
|
createInMemoryFileProvider,
|
|
@@ -150,7 +151,7 @@ describe("export-audit", () => {
|
|
|
150
151
|
|
|
151
152
|
test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
|
|
152
153
|
const assetName = "Großanlage";
|
|
153
|
-
for (let day = 0; day <
|
|
154
|
+
for (let day = 0; day < MAX_LIST_LIMIT; day += 1) {
|
|
154
155
|
const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
|
|
155
156
|
await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
|
|
156
157
|
}
|
|
@@ -185,6 +186,7 @@ import {
|
|
|
185
186
|
createTextField,
|
|
186
187
|
defineFeature,
|
|
187
188
|
defineWriteHandler,
|
|
189
|
+
MAX_LIST_LIMIT,
|
|
188
190
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
189
191
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
190
192
|
import {
|
|
@@ -221,7 +223,7 @@ function toFileNameSegment(text: string): string {
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
|
|
224
|
-
const AUDIT_ROW_LIMIT =
|
|
226
|
+
const AUDIT_ROW_LIMIT = MAX_LIST_LIMIT - 1;
|
|
225
227
|
|
|
226
228
|
const access = { access: { roles: ["Admin", "User"] } } as const;
|
|
227
229
|
|
|
@@ -492,6 +492,7 @@ export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
|
|
|
492
492
|
|
|
493
493
|
import type { EventDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
494
494
|
import { z } from "zod";
|
|
495
|
+
import { INVOICE_STATES } from "./entities/invoice.ts";
|
|
495
496
|
|
|
496
497
|
export const FEATURE_NAME = "billing";
|
|
497
498
|
export const ENTITY_NAME = "invoice";
|
|
@@ -523,8 +524,8 @@ export const INVOICE_CREATED_EVENT = `${ENTITY_NAME}.created` as const;
|
|
|
523
524
|
|
|
524
525
|
export const emptyEventPayload = z.object({});
|
|
525
526
|
export const statusForcedPayload = z.object({
|
|
526
|
-
newStatus: z.enum(
|
|
527
|
-
fromStatus: z.enum(
|
|
527
|
+
newStatus: z.enum(INVOICE_STATES),
|
|
528
|
+
fromStatus: z.enum(INVOICE_STATES),
|
|
528
529
|
});
|
|
529
530
|
|
|
530
531
|
// The EventDefs r.defineEvent returns inside the feature setup. Handlers receive them so that
|