@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1
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 +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/webhook-step.md +11 -1
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +29 -4
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pg-kms-adapter.js +10 -6
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +85 -58
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +183 -48
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +4 -4
- package/src/changes.json +323 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/dashboard-stats. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# dashboard-stats
|
|
4
|
+
|
|
5
|
+
Key figures on a dashboard: stat panels backed by one counting query.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Dashboard stats
|
|
10
|
+
|
|
11
|
+
Key figures on a dashboard. This recipe ships a `ticket` entity, one query
|
|
12
|
+
handler that returns the figures as a flat object, and a `dashboard` screen
|
|
13
|
+
with a stat group (open, closed) and a single stat (total).
|
|
14
|
+
|
|
15
|
+
The point is to show how the three pieces connect: the screen only names a
|
|
16
|
+
query and the fields to read, the query does the counting.
|
|
17
|
+
|
|
18
|
+
## What it shows
|
|
19
|
+
|
|
20
|
+
- **Panels reference a query by qualified name**: each `stat` carries
|
|
21
|
+
`query: "tickets:query:ticket:stats"`. The dashboard calls that handler and
|
|
22
|
+
renders the result.
|
|
23
|
+
- **`valueField` picks a field from a flat result**: the handler returns
|
|
24
|
+
`{ open, closed, total }`, and each stat names one of those keys.
|
|
25
|
+
- **`stat-group` and `stat`**: a group shows several stats side by side under
|
|
26
|
+
one title, a top-level `stat` stands alone.
|
|
27
|
+
- **Counting happens in the database**: the handler calls the executor's
|
|
28
|
+
`list` with `filter`, `limit: 1` and `totalCount: true`. The total is counted
|
|
29
|
+
over the whole WHERE clause, so no rows are loaded just to be counted.
|
|
30
|
+
- **Labels are translation keys**: declared with `r.translations`.
|
|
31
|
+
|
|
32
|
+
## When to reach for it
|
|
33
|
+
|
|
34
|
+
You want a landing screen with a few numbers: open items, overdue invoices,
|
|
35
|
+
this month's revenue. Write one query per screen that returns every figure,
|
|
36
|
+
and let the stats pick their field.
|
|
37
|
+
|
|
38
|
+
## Source
|
|
39
|
+
|
|
40
|
+
The whole feature lives in `src/feature.ts` (~100 lines). The integration test
|
|
41
|
+
checks that every stat reads a field the query returns, and that the query
|
|
42
|
+
counts open and closed tickets correctly.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bun kumiko test integration samples/dashboard-stats
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Source files
|
|
49
|
+
|
|
50
|
+
### src/__tests__/feature.integration.test.ts
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// Dashboard Stats Sample, integration test.
|
|
54
|
+
// Proves: the dashboard screen boots, its panels point at a registered query,
|
|
55
|
+
// and that query counts per status in the database.
|
|
56
|
+
|
|
57
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
58
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
59
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
60
|
+
import {
|
|
61
|
+
createTestUser,
|
|
62
|
+
setupTestStack,
|
|
63
|
+
type TestStack,
|
|
64
|
+
testTenantId,
|
|
65
|
+
unsafeCreateEntityTable,
|
|
66
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
67
|
+
import { TICKET_STATS_QUERY, ticketEntity, ticketFeature } from "../feature";
|
|
68
|
+
|
|
69
|
+
let stack: TestStack;
|
|
70
|
+
|
|
71
|
+
const admin = createTestUser({ roles: ["Admin"], tenantId: testTenantId(31) });
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
stack = await setupTestStack({ features: [ticketFeature] });
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, ticketEntity);
|
|
76
|
+
await createEventsTable(stack.db);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await stack.cleanup();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("dashboard screen", () => {
|
|
84
|
+
test("boots, and every stat reads a field the stats query returns", async () => {
|
|
85
|
+
const screen = ticketFeature.screens["ticket-dashboard"];
|
|
86
|
+
if (screen?.type !== "dashboard") throw new Error("ticket-dashboard is not a dashboard screen");
|
|
87
|
+
|
|
88
|
+
const stats = screen.panels.flatMap((panel) =>
|
|
89
|
+
panel.kind === "stat-group" ? panel.stats : panel.kind === "stat" ? [panel] : [],
|
|
90
|
+
);
|
|
91
|
+
expect(stats.map((s) => s.valueField)).toEqual(["open", "closed", "total"]);
|
|
92
|
+
for (const stat of stats) expect(stat.query).toBe(TICKET_STATS_QUERY);
|
|
93
|
+
|
|
94
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
|
|
95
|
+
for (const stat of stats) expect(result).toHaveProperty(stat.valueField);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("ticket:stats", () => {
|
|
100
|
+
test("counts open and closed tickets in the database", async () => {
|
|
101
|
+
for (const [title, status] of [
|
|
102
|
+
["a", "open"],
|
|
103
|
+
["b", "open"],
|
|
104
|
+
["c", "open"],
|
|
105
|
+
["d", "closed"],
|
|
106
|
+
["e", "closed"],
|
|
107
|
+
] as const) {
|
|
108
|
+
await stack.http.writeOk<SaveContext>(
|
|
109
|
+
"tickets:write:ticket:create",
|
|
110
|
+
{ title, status },
|
|
111
|
+
admin,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await stack.http.queryOk<Record<string, unknown>>(TICKET_STATS_QUERY, {}, admin);
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({ open: 3, closed: 2, total: 5 });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### src/feature.ts
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Dashboard Stats Sample
|
|
126
|
+
// Shows: a dashboard screen with a stat-group and a single stat, backed by one
|
|
127
|
+
// query handler that counts in the database.
|
|
128
|
+
|
|
129
|
+
import {
|
|
130
|
+
createEntity,
|
|
131
|
+
createEntityExecutor,
|
|
132
|
+
createSelectField,
|
|
133
|
+
createTextField,
|
|
134
|
+
defineFeature,
|
|
135
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
136
|
+
import { z } from "zod";
|
|
137
|
+
|
|
138
|
+
export const TICKET_STATUSES = ["open", "closed"] as const;
|
|
139
|
+
|
|
140
|
+
export const ticketEntity = createEntity({
|
|
141
|
+
table: "read_sample_tickets",
|
|
142
|
+
fields: {
|
|
143
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
144
|
+
status: createSelectField({ options: TICKET_STATUSES, default: "open", required: true }),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
|
|
149
|
+
|
|
150
|
+
const readerRoles = { roles: ["Admin", "User"] } as const;
|
|
151
|
+
|
|
152
|
+
export const ticketFeature = defineFeature("tickets", (r) => {
|
|
153
|
+
const { executor: ticketExecutor } = createEntityExecutor("ticket", ticketEntity);
|
|
154
|
+
|
|
155
|
+
r.crud("ticket", ticketEntity, {
|
|
156
|
+
write: { access: { roles: ["Admin"] } },
|
|
157
|
+
read: { access: readerRoles },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Flat result: each dashboard stat reads one field via its valueField.
|
|
161
|
+
// limit: 1 keeps the page tiny; totalCount counts over the whole WHERE in
|
|
162
|
+
// the database, so no rows are loaded just to be counted.
|
|
163
|
+
r.queryHandler(
|
|
164
|
+
"ticket:stats",
|
|
165
|
+
z.object({}),
|
|
166
|
+
async (query, ctx) => {
|
|
167
|
+
const countWithStatus = async (status: (typeof TICKET_STATUSES)[number]): Promise<number> => {
|
|
168
|
+
const page = await ticketExecutor.list(
|
|
169
|
+
{ filter: { field: "status", op: "eq", value: status }, limit: 1, totalCount: true },
|
|
170
|
+
query.user,
|
|
171
|
+
ctx.db,
|
|
172
|
+
);
|
|
173
|
+
return page.total ?? 0;
|
|
174
|
+
};
|
|
175
|
+
const [open, closed] = await Promise.all([
|
|
176
|
+
countWithStatus("open"),
|
|
177
|
+
countWithStatus("closed"),
|
|
178
|
+
]);
|
|
179
|
+
return { open, closed, total: open + closed };
|
|
180
|
+
},
|
|
181
|
+
{ access: readerRoles },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
r.translations({
|
|
185
|
+
keys: {
|
|
186
|
+
"tickets.dashboard.group": { en: "Tickets by status" },
|
|
187
|
+
"tickets.dashboard.open": { en: "Open" },
|
|
188
|
+
"tickets.dashboard.closed": { en: "Closed" },
|
|
189
|
+
"tickets.dashboard.total": { en: "All tickets" },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
r.screen({
|
|
194
|
+
id: "ticket-dashboard",
|
|
195
|
+
type: "dashboard",
|
|
196
|
+
panels: [
|
|
197
|
+
{
|
|
198
|
+
kind: "stat-group",
|
|
199
|
+
id: "by-status",
|
|
200
|
+
label: "tickets.dashboard.group",
|
|
201
|
+
stats: [
|
|
202
|
+
{
|
|
203
|
+
kind: "stat",
|
|
204
|
+
id: "open",
|
|
205
|
+
label: "tickets.dashboard.open",
|
|
206
|
+
query: TICKET_STATS_QUERY,
|
|
207
|
+
valueField: "open",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
kind: "stat",
|
|
211
|
+
id: "closed",
|
|
212
|
+
label: "tickets.dashboard.closed",
|
|
213
|
+
query: TICKET_STATS_QUERY,
|
|
214
|
+
valueField: "closed",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
kind: "stat",
|
|
220
|
+
id: "total",
|
|
221
|
+
label: "tickets.dashboard.total",
|
|
222
|
+
query: TICKET_STATS_QUERY,
|
|
223
|
+
valueField: "total",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
access: readerRoles,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
```
|
|
@@ -155,7 +155,7 @@ import {
|
|
|
155
155
|
createConfigFeature,
|
|
156
156
|
createConfigResolver,
|
|
157
157
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
158
|
-
import {
|
|
158
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
159
159
|
import {
|
|
160
160
|
setupTestStack,
|
|
161
161
|
type TestStack,
|
|
@@ -166,10 +166,10 @@ import {
|
|
|
166
166
|
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
167
167
|
import { billingFeature, stripeApiKeyHandle } from "../feature";
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
// Each test gets its own pair of tenants, so config rows from other tests never show up.
|
|
170
|
+
let acmeAdmin = { ...TestUsers.admin, tenantId: testTenantId(1) };
|
|
171
|
+
let globexAdmin = { ...TestUsers.admin, tenantId: testTenantId(2), id: "globex-admin" };
|
|
172
|
+
let nextTenantPair = 0;
|
|
173
173
|
|
|
174
174
|
let stack: TestStack;
|
|
175
175
|
|
|
@@ -190,8 +190,14 @@ beforeAll(async () => {
|
|
|
190
190
|
|
|
191
191
|
afterAll(async () => stack?.cleanup());
|
|
192
192
|
|
|
193
|
-
beforeEach(
|
|
194
|
-
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
nextTenantPair += 1;
|
|
195
|
+
acmeAdmin = { ...TestUsers.admin, tenantId: testTenantId(nextTenantPair * 2 + 1) };
|
|
196
|
+
globexAdmin = {
|
|
197
|
+
...TestUsers.admin,
|
|
198
|
+
tenantId: testTenantId(nextTenantPair * 2 + 2),
|
|
199
|
+
id: "globex-admin",
|
|
200
|
+
};
|
|
195
201
|
});
|
|
196
202
|
|
|
197
203
|
describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
|
|
@@ -203,7 +209,10 @@ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
|
|
|
203
209
|
acmeAdmin,
|
|
204
210
|
);
|
|
205
211
|
|
|
206
|
-
const rows = await selectMany(stack.db, configValuesTable, {
|
|
212
|
+
const rows = await selectMany(stack.db, configValuesTable, {
|
|
213
|
+
key: stripeApiKeyHandle.name,
|
|
214
|
+
tenantId: acmeAdmin.tenantId,
|
|
215
|
+
});
|
|
207
216
|
expect(rows.length).toBe(1);
|
|
208
217
|
const stored = rows[0]?.["value"];
|
|
209
218
|
expect(typeof stored).toBe("string");
|
|
@@ -115,8 +115,7 @@ The chain lands in the log (for forensics), but **not** in the response to the c
|
|
|
115
115
|
// documented wire shape. If you extend the sample, add a matching test here
|
|
116
116
|
// so the "copy this" guarantee stays valid.
|
|
117
117
|
|
|
118
|
-
import { afterAll, beforeAll,
|
|
119
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
118
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
120
119
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
121
120
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
122
121
|
import {
|
|
@@ -142,9 +141,6 @@ beforeAll(async () => {
|
|
|
142
141
|
await createEventsTable(stack.db);
|
|
143
142
|
});
|
|
144
143
|
afterAll(async () => stack.cleanup());
|
|
145
|
-
beforeEach(async () => {
|
|
146
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${orderTable.tableName}"`);
|
|
147
|
-
});
|
|
148
144
|
|
|
149
145
|
// --- 1) Zod validation error via schema: empty cart rejected ---
|
|
150
146
|
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
GlobalFeatureToggleRuntime,
|
|
40
40
|
globalFeatureStateTable,
|
|
41
41
|
} from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
|
|
42
|
-
import {
|
|
42
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
43
43
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
44
|
import {
|
|
45
45
|
createTestUser,
|
|
@@ -89,21 +89,36 @@ afterAll(async () => {
|
|
|
89
89
|
await stack?.cleanup();
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
beforeEach(async () => {
|
|
93
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${productAuditTable.tableName}"`);
|
|
94
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
|
|
95
|
-
await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
|
|
96
|
-
await runtime.refresh();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
92
|
const admin = createTestUser({
|
|
100
93
|
id: "11111111-1111-1111-1111-111111111111",
|
|
101
94
|
tenantId: SYSTEM_TENANT_ID,
|
|
102
95
|
roles: ["SystemAdmin"],
|
|
103
96
|
});
|
|
104
97
|
|
|
98
|
+
// Products and audit rows are system-wide by design, so tests isolate through
|
|
99
|
+
// product names that are unique per test instead of per tenant.
|
|
100
|
+
let testNumber = 0;
|
|
101
|
+
let suffix = "";
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
testNumber += 1;
|
|
105
|
+
suffix = `-t${testNumber}`;
|
|
106
|
+
runtime.apply("product", true);
|
|
107
|
+
runtime.apply("product-audit", true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function auditNames(): Promise<string[]> {
|
|
111
|
+
const rows = await selectMany(stack.db, productAuditTable);
|
|
112
|
+
return rows.map((row) => String(row["productName"])).filter((name) => name.endsWith(suffix));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function productNames(): Promise<string[]> {
|
|
116
|
+
const rows = await selectMany(stack.db, productTable);
|
|
117
|
+
return rows.map((row) => String(row["name"])).filter((name) => name.endsWith(suffix));
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
async function createProduct(name: string) {
|
|
106
|
-
return stack.http.write("product:write:product:create", { name }, admin);
|
|
121
|
+
return stack.http.write("product:write:product:create", { name: `${name}${suffix}` }, admin);
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
describe("feature-toggles showcase", () => {
|
|
@@ -111,8 +126,7 @@ describe("feature-toggles showcase", () => {
|
|
|
111
126
|
// ON
|
|
112
127
|
const ok = await createProduct("alpha");
|
|
113
128
|
expect((await ok.json()).isSuccess).toBe(true);
|
|
114
|
-
|
|
115
|
-
expect(auditRowsAfterOn.length).toBe(1);
|
|
129
|
+
expect(await auditNames()).toHaveLength(1);
|
|
116
130
|
|
|
117
131
|
// OFF
|
|
118
132
|
runtime.apply("product", false);
|
|
@@ -127,8 +141,7 @@ describe("feature-toggles showcase", () => {
|
|
|
127
141
|
runtime.apply("product", true);
|
|
128
142
|
const again = await createProduct("gamma");
|
|
129
143
|
expect((await again.json()).isSuccess).toBe(true);
|
|
130
|
-
|
|
131
|
-
expect(auditRowsAfterReEnable.length).toBe(2);
|
|
144
|
+
expect(await auditNames()).toHaveLength(2);
|
|
132
145
|
});
|
|
133
146
|
|
|
134
147
|
test("cross-feature hook: disabling product-audit skips its hook but product keeps working", async () => {
|
|
@@ -137,11 +150,9 @@ describe("feature-toggles showcase", () => {
|
|
|
137
150
|
const ok = await createProduct("delta");
|
|
138
151
|
expect((await ok.json()).isSuccess).toBe(true);
|
|
139
152
|
// Product row written — handler unaffected.
|
|
140
|
-
|
|
141
|
-
expect(productRows.length).toBe(1);
|
|
153
|
+
expect(await productNames()).toHaveLength(1);
|
|
142
154
|
// Audit hook owned by product-audit — silently skipped.
|
|
143
|
-
|
|
144
|
-
expect(auditRows.length).toBe(0);
|
|
155
|
+
expect(await auditNames()).toHaveLength(0);
|
|
145
156
|
});
|
|
146
157
|
});
|
|
147
158
|
```
|
|
@@ -72,17 +72,19 @@ import {
|
|
|
72
72
|
createLedgerFeature,
|
|
73
73
|
transactionEntity,
|
|
74
74
|
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
75
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
76
75
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
77
76
|
import {
|
|
78
77
|
createTestUser,
|
|
79
78
|
setupTestStack,
|
|
80
79
|
type TestStack,
|
|
80
|
+
testTenantId,
|
|
81
81
|
unsafeCreateEntityTable,
|
|
82
82
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
83
83
|
import { bankingFlow, type LedgerClient } from "../usage";
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
86
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
87
|
+
let nextTenant = 1;
|
|
86
88
|
const ledger = createLedgerFeature();
|
|
87
89
|
|
|
88
90
|
let stack: TestStack;
|
|
@@ -104,10 +106,9 @@ afterAll(async () => {
|
|
|
104
106
|
await stack.cleanup();
|
|
105
107
|
});
|
|
106
108
|
|
|
107
|
-
beforeEach(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
nextTenant += 1;
|
|
111
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
describe("ledger-banking recipe — transfer + derived balance", () => {
|
|
@@ -81,17 +81,19 @@ import {
|
|
|
81
81
|
createLedgerFeature,
|
|
82
82
|
transactionEntity,
|
|
83
83
|
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
84
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
85
84
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
86
85
|
import {
|
|
87
86
|
createTestUser,
|
|
88
87
|
setupTestStack,
|
|
89
88
|
type TestStack,
|
|
89
|
+
testTenantId,
|
|
90
90
|
unsafeCreateEntityTable,
|
|
91
91
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
92
92
|
import { invoicingFlow, type LedgerClient } from "../usage";
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
95
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
96
|
+
let nextTenant = 1;
|
|
95
97
|
const ledger = createLedgerFeature();
|
|
96
98
|
|
|
97
99
|
let stack: TestStack;
|
|
@@ -112,10 +114,9 @@ afterAll(async () => {
|
|
|
112
114
|
await stack.cleanup();
|
|
113
115
|
});
|
|
114
116
|
|
|
115
|
-
beforeEach(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
nextTenant += 1;
|
|
119
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
119
120
|
});
|
|
120
121
|
|
|
121
122
|
describe("ledger-invoicing recipe — accrual + receivable lifecycle", () => {
|
|
@@ -95,8 +95,8 @@ imprint" decision.
|
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
97
|
# In the app workspace:
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
bunx kumiko-schema generate <name> # detects text-block entity → SQL migration
|
|
99
|
+
bunx kumiko-schema apply # one-time (pre-deploy step in prod)
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
### 3. Initial seed of the required blocks
|
|
@@ -89,8 +89,8 @@ the recipe lists `createConfigFeature()` explicitly (see `src/feature.ts`).
|
|
|
89
89
|
### 2. Create the table
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
bunx kumiko-schema generate <name> # detects the `page` entity → SQL migration
|
|
93
|
+
bunx kumiko-schema apply
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
### 3. Authoring + branding
|
|
@@ -121,18 +121,20 @@ import {
|
|
|
121
121
|
noteMentionEntity,
|
|
122
122
|
notesHistoryFeature,
|
|
123
123
|
} from "@cosmicdrift/kumiko-bundled-features/notes-history";
|
|
124
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
125
124
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
126
125
|
import {
|
|
127
126
|
createTestUser,
|
|
128
127
|
setupTestStack,
|
|
129
128
|
type TestStack,
|
|
129
|
+
testTenantId,
|
|
130
130
|
unsafeCreateEntityTable,
|
|
131
131
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
132
132
|
import { taskEntity, taskFeature } from "../feature";
|
|
133
133
|
import { type NotesClient, notesFlow } from "../usage";
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
136
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
137
|
+
let nextTenant = 1;
|
|
136
138
|
|
|
137
139
|
// Adapt the test stack to the host-facing NotesClient the docs embed uses.
|
|
138
140
|
const notesClient: NotesClient = {
|
|
@@ -154,10 +156,9 @@ afterAll(async () => {
|
|
|
154
156
|
await stack.cleanup();
|
|
155
157
|
});
|
|
156
158
|
|
|
157
|
-
beforeEach(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
nextTenant += 1;
|
|
161
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
161
162
|
});
|
|
162
163
|
|
|
163
164
|
async function createTask(id: string, title: string) {
|
|
@@ -39,7 +39,7 @@ bun test
|
|
|
39
39
|
// feature. Each test maps directly to a cell in the design-doc matrix.
|
|
40
40
|
|
|
41
41
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
42
|
-
import {
|
|
42
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
43
43
|
import {
|
|
44
44
|
createTestUser,
|
|
45
45
|
setupTestStack,
|
|
@@ -52,26 +52,33 @@ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
|
52
52
|
import { contractEntity, contractsFeature, teamsFeature } from "../feature";
|
|
53
53
|
|
|
54
54
|
let stack: TestStack;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
55
|
+
|
|
56
|
+
function usersForTenant(tenant: TenantId) {
|
|
57
|
+
return {
|
|
58
|
+
admin: { ...TestUsers.admin, tenantId: tenant },
|
|
59
|
+
teamMemberEng: createTestUser({
|
|
60
|
+
id: "11111111-0000-4000-8000-000000000001",
|
|
61
|
+
tenantId: tenant,
|
|
62
|
+
roles: ["TeamMember"],
|
|
63
|
+
claims: { "teams:teamId": "eng" },
|
|
64
|
+
}),
|
|
65
|
+
teamMemberOps: createTestUser({
|
|
66
|
+
id: "11111111-0000-4000-8000-000000000002",
|
|
67
|
+
tenantId: tenant,
|
|
68
|
+
roles: ["TeamMember"],
|
|
69
|
+
claims: { "teams:teamId": "ops" },
|
|
70
|
+
}),
|
|
71
|
+
driverAlice: createTestUser({
|
|
72
|
+
id: "22222222-0000-4000-8000-000000000001",
|
|
73
|
+
tenantId: tenant,
|
|
74
|
+
roles: ["Driver"],
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Each test runs in its own tenant, so contract rows from other tests never show up.
|
|
80
|
+
let nextTenant = 1;
|
|
81
|
+
let { admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant));
|
|
75
82
|
|
|
76
83
|
beforeAll(async () => {
|
|
77
84
|
stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
|
|
@@ -87,7 +94,8 @@ let engRowVersion: number;
|
|
|
87
94
|
let opsRowId: string;
|
|
88
95
|
|
|
89
96
|
beforeEach(async () => {
|
|
90
|
-
|
|
97
|
+
nextTenant += 1;
|
|
98
|
+
({ admin, teamMemberEng, teamMemberOps, driverAlice } = usersForTenant(testTenantId(nextTenant)));
|
|
91
99
|
const eng = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
92
100
|
"contracts:write:contract:create",
|
|
93
101
|
{
|