@cosmicdrift/kumiko-framework 0.398.0 → 0.400.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 +33 -19
- package/agents/api/dev-server-testing.md +23 -0
- package/agents/api/framework-db.md +277 -0
- package/agents/api/framework-engine.md +1810 -0
- package/agents/api/framework-errors.md +119 -0
- package/agents/api/framework-event-store.md +99 -0
- package/agents/api/framework-seeding.md +26 -0
- package/agents/api/framework-ui-types.md +542 -0
- package/agents/api/renderer-web.md +1450 -0
- package/agents/api/testing-helpers.md +189 -0
- package/agents/api/testing-stack.md +144 -0
- package/agents/api/types.md +5124 -0
- package/agents/api.md +20 -0
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples.md +3 -0
- package/agents/features-index.md +178 -0
- package/agents/features.md +9 -1
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +188 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +33 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +103 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +178 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
- package/dist/engine/boot-validator/i18n-keys.js +1 -3
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +2 -7
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +54 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +1 -1
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +5 -1
- package/dist/engine/entity-list-runtime-options.js +103 -4
- package/dist/engine/factories.d.ts +5 -5
- package/dist/engine/factories.js +3 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +2 -1
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/entrypoint/index.js +1 -0
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +30 -9
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +4 -1
- package/dist/files/upload-field-resolution.js +57 -12
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- package/src/changes.json +67 -0
package/agents/api.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from package.json exports of the listed entry points. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# API reference
|
|
4
|
+
|
|
5
|
+
Public signatures of the entry points an app imports, generated from their sources. Read these instead of `dist/*.d.ts`.
|
|
6
|
+
|
|
7
|
+
Format: one line per function or constant, object and options types as property lists (`name?: Type;`), and the first sentence of the doc comment after `//`. Bodies and `@internal` declarations are left out. A type that an entry point uses but does not export is listed in `api/types.md`.
|
|
8
|
+
|
|
9
|
+
| Import path | File | Exports |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 411 |
|
|
12
|
+
| `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
|
|
13
|
+
| `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
|
|
14
|
+
| `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
|
|
15
|
+
| `@cosmicdrift/kumiko-framework/event-store` | `api/framework-event-store.md` | 38 |
|
|
16
|
+
| `@cosmicdrift/kumiko-framework/seeding` | `api/framework-seeding.md` | 6 |
|
|
17
|
+
| `@cosmicdrift/kumiko-renderer/web` | `api/renderer-web.md` | 298 |
|
|
18
|
+
| `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 21 |
|
|
19
|
+
| `@cosmicdrift/kumiko-testing/helpers` | `api/testing-helpers.md` | 71 |
|
|
20
|
+
| `@cosmicdrift/kumiko-dev-server/testing` | `api/dev-server-testing.md` | 5 |
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/derived-fields-sql. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# derived-fields-sql
|
|
4
|
+
|
|
5
|
+
SQL derived fields on a list: coalesce, related count with a where, related first (text and money), sortable columns and a defaultSort on a derived value.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Derived fields (SQL)
|
|
10
|
+
|
|
11
|
+
A derived field is read-time only: no column, never in a write schema. A `sql` derived field is
|
|
12
|
+
computed inside the list SELECT as the viewer, so it can be sorted on the server and never
|
|
13
|
+
enters the entity cache. A viewer who cannot read a field the value depends on gets NULL.
|
|
14
|
+
|
|
15
|
+
## What it shows
|
|
16
|
+
|
|
17
|
+
- `coalesce`: the nickname when set, else the name.
|
|
18
|
+
- `related` + `count` with a `where`: open tickets per building.
|
|
19
|
+
- `related` + `first`: the title of the newest ticket.
|
|
20
|
+
- `related` + `first` on a money field with `valueType: "money"`: amount and currency come
|
|
21
|
+
back in the shape of a stored money field (no key when the value is unset).
|
|
22
|
+
- `sortable: true` on a derived field and an `entityList` `defaultSort` on it.
|
|
23
|
+
|
|
24
|
+
## Source
|
|
25
|
+
|
|
26
|
+
Feature entry point: `src/feature.ts`.
|
|
27
|
+
|
|
28
|
+
## Tests
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd samples/recipes/derived-fields-sql
|
|
32
|
+
bun test
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`src/__tests__/feature.integration.test.ts` runs the list query over HTTP.
|
|
36
|
+
|
|
37
|
+
## Source files
|
|
38
|
+
|
|
39
|
+
### src/__tests__/feature.integration.test.ts
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
43
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
45
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
46
|
+
import { buildingListSort, derivedFieldsSqlFeature } from "../feature";
|
|
47
|
+
|
|
48
|
+
type Row = Record<string, unknown>;
|
|
49
|
+
type Page = { readonly rows: readonly Row[] };
|
|
50
|
+
|
|
51
|
+
const LIST = "derived-fields-sql:query:building:list";
|
|
52
|
+
const user = createTestUser({ roles: ["User"], tenantId: testTenantId(1) });
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
const names = (page: Page): unknown[] => page.rows.map((row) => row["name"]);
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
stack = await setupTestStack({ features: [derivedFieldsSqlFeature], systemHooks: [] });
|
|
59
|
+
const create = async (name: string, nickname?: string): Promise<string> => {
|
|
60
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
61
|
+
"derived-fields-sql:write:building:create",
|
|
62
|
+
{ name, ...(nickname !== undefined && { nickname }) },
|
|
63
|
+
user,
|
|
64
|
+
);
|
|
65
|
+
return created.id;
|
|
66
|
+
};
|
|
67
|
+
const ticket = (
|
|
68
|
+
buildingId: string,
|
|
69
|
+
title: string,
|
|
70
|
+
status: string,
|
|
71
|
+
openedOn: string,
|
|
72
|
+
cost?: { amount: number; currency: string },
|
|
73
|
+
) =>
|
|
74
|
+
stack.http.writeOk(
|
|
75
|
+
"derived-fields-sql:write:ticket:create",
|
|
76
|
+
{ buildingId, title, status, openedOn, ...(cost !== undefined && { cost }) },
|
|
77
|
+
user,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const harbor = await create("Harbor House", "The Harbor");
|
|
81
|
+
const mill = await create("Old Mill");
|
|
82
|
+
await create("Quiet Court");
|
|
83
|
+
await ticket(harbor, "Roof leak", "open", "2026-01-10", { amount: 800, currency: "EUR" });
|
|
84
|
+
await ticket(harbor, "Boiler", "inProgress", "2026-03-05", { amount: 1250.5, currency: "EUR" });
|
|
85
|
+
await ticket(harbor, "Paint hall", "done", "2026-05-01");
|
|
86
|
+
await ticket(mill, "Broken window", "open", "2026-02-02", { amount: 90, currency: "CHF" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await stack.cleanup();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("derived-fields-sql recipe", () => {
|
|
94
|
+
test("coalesce falls back to the name when there is no nickname", async () => {
|
|
95
|
+
const page = await stack.http.queryOk<Page>(LIST, { limit: 10 }, user);
|
|
96
|
+
const shown = new Map(page.rows.map((row) => [row["name"], row["shownName"]]));
|
|
97
|
+
|
|
98
|
+
expect(shown.get("Harbor House")).toBe("The Harbor");
|
|
99
|
+
expect(shown.get("Old Mill")).toBe("Old Mill");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("related count follows the where, first picks the newest ticket", async () => {
|
|
103
|
+
const page = await stack.http.queryOk<Page>(LIST, { limit: 10 }, user);
|
|
104
|
+
const harbor = page.rows.find((row) => row["name"] === "Harbor House");
|
|
105
|
+
const quiet = page.rows.find((row) => row["name"] === "Quiet Court");
|
|
106
|
+
|
|
107
|
+
expect(harbor?.["openTickets"]).toBe(2);
|
|
108
|
+
// `first` ignores the where: it is the newest ticket by openedOn, done or not.
|
|
109
|
+
expect(harbor?.["latestTicket"]).toBe("Paint hall");
|
|
110
|
+
expect(quiet?.["openTickets"]).toBe(0);
|
|
111
|
+
expect(quiet?.["latestTicket"]).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("first on a money field arrives as amount and currency, absent when unset", async () => {
|
|
115
|
+
const page = await stack.http.queryOk<Page>(LIST, { limit: 10 }, user);
|
|
116
|
+
const byName = new Map(page.rows.map((row) => [row["name"], row]));
|
|
117
|
+
|
|
118
|
+
expect(byName.get("Old Mill")?.["latestCost"]).toMatchObject({ amount: 90, currency: "CHF" });
|
|
119
|
+
// Harbor's newest ticket has no cost: no key, like an unset stored money field.
|
|
120
|
+
expect(Object.hasOwn(byName.get("Harbor House") ?? {}, "latestCost")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a sortable derived column sorts on the server", async () => {
|
|
124
|
+
const asc = await stack.http.queryOk<Page>(
|
|
125
|
+
LIST,
|
|
126
|
+
{ sort: "shownName", sortDirection: "asc", limit: 10 },
|
|
127
|
+
user,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(names(asc)).toEqual(["Old Mill", "Quiet Court", "Harbor House"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the screen's defaultSort on a derived value orders the list", async () => {
|
|
134
|
+
const screen = createRegistry([derivedFieldsSqlFeature]).getScreen(
|
|
135
|
+
"derived-fields-sql:screen:building-list",
|
|
136
|
+
);
|
|
137
|
+
const defaultSort = screen?.type === "entityList" ? screen.defaultSort : undefined;
|
|
138
|
+
expect(defaultSort).toEqual(buildingListSort);
|
|
139
|
+
|
|
140
|
+
const page = await stack.http.queryOk<Page>(
|
|
141
|
+
LIST,
|
|
142
|
+
{ sort: defaultSort?.field, sortDirection: defaultSort?.dir, limit: 10 },
|
|
143
|
+
user,
|
|
144
|
+
);
|
|
145
|
+
expect(names(page)).toEqual(["Harbor House", "Old Mill", "Quiet Court"]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### src/feature.ts
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import {
|
|
154
|
+
createDateField,
|
|
155
|
+
createDerivedField,
|
|
156
|
+
createEntity,
|
|
157
|
+
createMoneyField,
|
|
158
|
+
createSelectField,
|
|
159
|
+
createTextField,
|
|
160
|
+
defineFeature,
|
|
161
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
162
|
+
|
|
163
|
+
const openAccess = {
|
|
164
|
+
access: {
|
|
165
|
+
openToAll: { reason: "recipe: any signed-in user reads and edits; no per-user ownership" },
|
|
166
|
+
},
|
|
167
|
+
} as const;
|
|
168
|
+
|
|
169
|
+
export const ticketEntity = createEntity({
|
|
170
|
+
table: "read_derived_sql_tickets",
|
|
171
|
+
fields: {
|
|
172
|
+
buildingId: { type: "reference", entity: "building" },
|
|
173
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
174
|
+
status: createSelectField({ options: ["open", "inProgress", "done"] as const }),
|
|
175
|
+
openedOn: createDateField({ required: true }),
|
|
176
|
+
cost: createMoneyField(),
|
|
177
|
+
},
|
|
178
|
+
// The sortable derived columns of the building run a subquery per row over this column.
|
|
179
|
+
indexes: [{ columns: ["buildingId"] }],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const buildingEntity = createEntity({
|
|
183
|
+
table: "read_derived_sql_buildings",
|
|
184
|
+
fields: {
|
|
185
|
+
name: createTextField({
|
|
186
|
+
personal: false,
|
|
187
|
+
reason: "is_business_data",
|
|
188
|
+
required: true,
|
|
189
|
+
searchable: true,
|
|
190
|
+
}),
|
|
191
|
+
nickname: createTextField({ personal: false, reason: "is_business_data" }),
|
|
192
|
+
},
|
|
193
|
+
// Computed inside the list SELECT as the viewer; never stored, never cached.
|
|
194
|
+
derivedFields: {
|
|
195
|
+
// First non-null of the columns, so the list shows the nickname when there is one.
|
|
196
|
+
shownName: createDerivedField({
|
|
197
|
+
valueType: "text",
|
|
198
|
+
sortable: true,
|
|
199
|
+
sql: { kind: "coalesce", fields: ["nickname", "name"] },
|
|
200
|
+
}),
|
|
201
|
+
openTickets: createDerivedField({
|
|
202
|
+
valueType: "number",
|
|
203
|
+
sortable: true,
|
|
204
|
+
sql: {
|
|
205
|
+
kind: "related",
|
|
206
|
+
entity: "ticket",
|
|
207
|
+
on: { field: "buildingId" },
|
|
208
|
+
measure: { kind: "count" },
|
|
209
|
+
where: [{ field: "status", op: "in", value: ["open", "inProgress"] }],
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
latestTicket: createDerivedField({
|
|
213
|
+
valueType: "text",
|
|
214
|
+
sql: {
|
|
215
|
+
kind: "related",
|
|
216
|
+
entity: "ticket",
|
|
217
|
+
on: { field: "buildingId" },
|
|
218
|
+
measure: { kind: "first", field: "title", orderBy: "openedOn", direction: "desc" },
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
// Money reads amount and currency together and has the shape of a stored money field.
|
|
222
|
+
latestCost: createDerivedField({
|
|
223
|
+
valueType: "money",
|
|
224
|
+
sortable: true,
|
|
225
|
+
sql: {
|
|
226
|
+
kind: "related",
|
|
227
|
+
entity: "ticket",
|
|
228
|
+
on: { field: "buildingId" },
|
|
229
|
+
measure: { kind: "first", field: "cost", orderBy: "openedOn", direction: "desc" },
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export const buildingListSort = { field: "openTickets", dir: "desc" } as const;
|
|
236
|
+
|
|
237
|
+
export const derivedFieldsSqlFeature = defineFeature("derived-fields-sql", (r) => {
|
|
238
|
+
r.crud("ticket", ticketEntity, { write: openAccess, read: openAccess });
|
|
239
|
+
r.crud("building", buildingEntity, { write: openAccess, read: openAccess });
|
|
240
|
+
|
|
241
|
+
r.screen({
|
|
242
|
+
id: "building-list",
|
|
243
|
+
type: "entityList",
|
|
244
|
+
entity: "building",
|
|
245
|
+
columns: ["shownName", "openTickets", "latestTicket", "latestCost"],
|
|
246
|
+
// A derived column sorts on the server only when it is `sortable: true`.
|
|
247
|
+
defaultSort: buildingListSort,
|
|
248
|
+
access: openAccess.access,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/documents. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# documents
|
|
4
|
+
|
|
5
|
+
A document register on top of existing records: captured files, expiry dates and per-record visibility.
|
|
6
|
+
|
|
7
|
+
## Source files
|
|
8
|
+
|
|
9
|
+
### src/__tests__/feature.integration.test.ts
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
createFilesFeature,
|
|
15
|
+
createInMemoryFileProvider,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
17
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
18
|
+
import {
|
|
19
|
+
buildMultipartBody,
|
|
20
|
+
patchFileInstanceofForBunTest,
|
|
21
|
+
} from "@cosmicdrift/kumiko-testing/helpers";
|
|
22
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
23
|
+
import * as z from "zod";
|
|
24
|
+
import { documents, propertyManagement } from "../feature.ts";
|
|
25
|
+
|
|
26
|
+
const tenant = testTenantId(1);
|
|
27
|
+
const admin = createTestUser({ id: 1, tenantId: tenant, roles: ["TenantAdmin"] });
|
|
28
|
+
const propertyReader = createTestUser({ id: 2, tenantId: tenant, roles: ["PropertyReader"] });
|
|
29
|
+
const contractReader = createTestUser({ id: 3, tenantId: tenant, roles: ["ContractReader"] });
|
|
30
|
+
|
|
31
|
+
type Page = { rows: readonly { title: string }[] };
|
|
32
|
+
|
|
33
|
+
let stack: TestStack;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
patchFileInstanceofForBunTest();
|
|
37
|
+
stack = await setupTestStack({
|
|
38
|
+
features: [createFilesFeature(), propertyManagement, documents],
|
|
39
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
afterAll(() => stack.cleanup());
|
|
43
|
+
|
|
44
|
+
async function uploadContractFile(): Promise<string> {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
form.append("entityType", "contract");
|
|
47
|
+
form.append("fieldName", "file");
|
|
48
|
+
form.append(
|
|
49
|
+
"file",
|
|
50
|
+
new File([Buffer.from("%PDF-1.4 lease")], "lease.pdf", { type: "application/pdf" }),
|
|
51
|
+
);
|
|
52
|
+
const { body, contentType } = await buildMultipartBody(form);
|
|
53
|
+
const res = await stack.app.request("/api/files", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: `Bearer ${await stack.jwt.sign(admin)}`,
|
|
57
|
+
"Content-Type": contentType,
|
|
58
|
+
},
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
return z.object({ id: z.string() }).parse(await res.json()).id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("documents recipe", () => {
|
|
65
|
+
test("a contract file becomes a document that only contract readers see", async () => {
|
|
66
|
+
const property = await stack.http.writeOk<{ id: string }>(
|
|
67
|
+
"property-management:write:property:create",
|
|
68
|
+
{ name: "Main street 1" },
|
|
69
|
+
admin,
|
|
70
|
+
);
|
|
71
|
+
const contract = await stack.http.writeOk<{ id: string }>(
|
|
72
|
+
"property-management:write:contract:create",
|
|
73
|
+
{ name: "C-1", propertyId: property.id, file: await uploadContractFile() },
|
|
74
|
+
admin,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const forRecord = (as: typeof admin, entityType: string, entityId: string) =>
|
|
78
|
+
stack.http.queryOk<Page>("documents:query:for-record", { entityType, entityId }, as);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
(await forRecord(contractReader, "contract", contract.id)).rows.map((r) => r.title),
|
|
82
|
+
).toEqual(["Contract C-1"]);
|
|
83
|
+
expect((await forRecord(admin, "property", property.id)).rows.map((r) => r.title)).toEqual([
|
|
84
|
+
"Contract C-1",
|
|
85
|
+
]);
|
|
86
|
+
// the property reader may open the property but not the contract that owns the file
|
|
87
|
+
expect((await forRecord(propertyReader, "property", property.id)).rows).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### src/feature.ts
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// kumiko-feature-version: 1
|
|
96
|
+
// Documents Sample
|
|
97
|
+
//
|
|
98
|
+
// A `contract` entity with a file field becomes a document source without knowing about the
|
|
99
|
+
// documents feature: the capture rule below files every saved contract file as a document of
|
|
100
|
+
// category "contract" and links it to the contract and to its property. Who sees the document
|
|
101
|
+
// follows from who may read the linked records and the contract that owns the file.
|
|
102
|
+
|
|
103
|
+
import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents";
|
|
104
|
+
import {
|
|
105
|
+
createEntity,
|
|
106
|
+
createFileField,
|
|
107
|
+
createTextField,
|
|
108
|
+
defineEntityCreateHandler,
|
|
109
|
+
defineEntityDetailHandler,
|
|
110
|
+
defineEntityListHandler,
|
|
111
|
+
defineEntityUpdateHandler,
|
|
112
|
+
defineFeature,
|
|
113
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
114
|
+
|
|
115
|
+
const text = () =>
|
|
116
|
+
createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 120 });
|
|
117
|
+
|
|
118
|
+
export const propertyEntity = createEntity({
|
|
119
|
+
table: "read_sample_documents_properties",
|
|
120
|
+
fields: { name: text() },
|
|
121
|
+
access: { read: { PropertyReader: "all", TenantAdmin: "all" } },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const contractEntity = createEntity({
|
|
125
|
+
table: "read_sample_documents_contracts",
|
|
126
|
+
fields: { name: text(), propertyId: text(), file: createFileField() },
|
|
127
|
+
access: { read: { ContractReader: "all", TenantAdmin: "all" } },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const access = { roles: ["PropertyReader", "ContractReader", "TenantAdmin"] };
|
|
131
|
+
|
|
132
|
+
export const propertyManagement = defineFeature("property-management", (r) => {
|
|
133
|
+
r.requires({ features: ["files", "documents"] });
|
|
134
|
+
r.entity({ name: "property", ...propertyEntity });
|
|
135
|
+
r.entity({ name: "contract", ...contractEntity });
|
|
136
|
+
for (const [name, entity] of [
|
|
137
|
+
["property", propertyEntity],
|
|
138
|
+
["contract", contractEntity],
|
|
139
|
+
] as const) {
|
|
140
|
+
r.writeHandler(defineEntityCreateHandler(name, entity, { access }));
|
|
141
|
+
r.writeHandler(defineEntityUpdateHandler(name, entity, { access: { roles: ["TenantAdmin"] } }));
|
|
142
|
+
r.queryHandler(defineEntityListHandler(name, entity, { access }));
|
|
143
|
+
r.queryHandler(defineEntityDetailHandler(name, entity, { access }));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const documents = createDocumentsFeature({
|
|
148
|
+
categories: ["contract", "insurance"],
|
|
149
|
+
linkTargets: ["property", "contract"],
|
|
150
|
+
access,
|
|
151
|
+
capture: [
|
|
152
|
+
{
|
|
153
|
+
entity: "contract",
|
|
154
|
+
field: "file",
|
|
155
|
+
category: "contract",
|
|
156
|
+
title: (row) => `Contract ${String(row["name"])}`,
|
|
157
|
+
links: (row) => [{ entityType: "property", entityId: String(row["propertyId"]) }],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
```
|