@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.
Files changed (143) hide show
  1. package/AGENTS.md +33 -19
  2. package/agents/api/dev-server-testing.md +23 -0
  3. package/agents/api/framework-db.md +277 -0
  4. package/agents/api/framework-engine.md +1810 -0
  5. package/agents/api/framework-errors.md +119 -0
  6. package/agents/api/framework-event-store.md +99 -0
  7. package/agents/api/framework-seeding.md +26 -0
  8. package/agents/api/framework-ui-types.md +542 -0
  9. package/agents/api/renderer-web.md +1450 -0
  10. package/agents/api/testing-helpers.md +189 -0
  11. package/agents/api/testing-stack.md +144 -0
  12. package/agents/api/types.md +5124 -0
  13. package/agents/api.md +20 -0
  14. package/agents/examples/derived-fields-sql.md +251 -0
  15. package/agents/examples/documents.md +161 -0
  16. package/agents/examples/inspection-checklist.md +807 -0
  17. package/agents/examples.md +3 -0
  18. package/agents/features-index.md +178 -0
  19. package/agents/features.md +9 -1
  20. package/dist/api/entity-read-access.d.ts +2 -0
  21. package/dist/api/entity-read-access.js +46 -2
  22. package/dist/api/server-boot-checks.js +2 -2
  23. package/dist/api/server-routes.js +3 -1
  24. package/dist/db/derived-field-sql.d.ts +23 -0
  25. package/dist/db/derived-field-sql.js +115 -0
  26. package/dist/db/derived-related-sql.d.ts +31 -0
  27. package/dist/db/derived-related-sql.js +137 -0
  28. package/dist/db/driver-row-coercion.d.ts +1 -0
  29. package/dist/db/driver-row-coercion.js +13 -0
  30. package/dist/db/eagerload.d.ts +3 -1
  31. package/dist/db/eagerload.js +4 -6
  32. package/dist/db/event-store-executor-context.js +1 -1
  33. package/dist/db/event-store-executor-read-paging.js +1 -1
  34. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  35. package/dist/db/event-store-executor-read-search.js +17 -9
  36. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  37. package/dist/db/event-store-executor-read-sort.js +27 -14
  38. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  39. package/dist/db/event-store-executor-read-where.js +3 -9
  40. package/dist/db/event-store-executor-read.js +23 -4
  41. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  42. package/dist/db/event-store-executor-write-steps.js +20 -1
  43. package/dist/db/event-store-executor-write.js +8 -8
  44. package/dist/db/money.d.ts +4 -0
  45. package/dist/db/money.js +34 -29
  46. package/dist/db/parent-ref-clause.d.ts +1 -0
  47. package/dist/db/parent-ref-clause.js +18 -8
  48. package/dist/db/read-gate-clause.d.ts +7 -0
  49. package/dist/db/read-gate-clause.js +188 -0
  50. package/dist/db/read-ownership.d.ts +9 -0
  51. package/dist/db/read-ownership.js +12 -0
  52. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  53. package/dist/engine/boot-validator/boot-check.js +7 -11
  54. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  55. package/dist/engine/boot-validator/boot-context.js +57 -0
  56. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  57. package/dist/engine/boot-validator/boot-options.js +1 -0
  58. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  59. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  60. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  61. package/dist/engine/boot-validator/boot-phases.js +140 -0
  62. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  63. package/dist/engine/boot-validator/boot-report.js +47 -0
  64. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  65. package/dist/engine/boot-validator/boot-rule.js +14 -0
  66. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  67. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  68. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  69. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  70. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  71. package/dist/engine/boot-validator/entity-handler.js +6 -3
  72. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  73. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  74. package/dist/engine/boot-validator/field-conditions.js +178 -0
  75. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  76. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  77. package/dist/engine/boot-validator/index.d.ts +5 -34
  78. package/dist/engine/boot-validator/index.js +24 -204
  79. package/dist/engine/boot-validator/pii-retention.js +2 -7
  80. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  81. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  82. package/dist/engine/boot-validator/read-gate.js +54 -0
  83. package/dist/engine/boot-validator/screens-forms.js +19 -6
  84. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  85. package/dist/engine/boot-validator/screens-lists.js +8 -0
  86. package/dist/engine/boot-validator/zod-shape.js +4 -2
  87. package/dist/engine/build-app-schema.js +11 -5
  88. package/dist/engine/embedded-derived.d.ts +8 -6
  89. package/dist/engine/embedded-derived.js +25 -7
  90. package/dist/engine/entity-handlers.d.ts +1 -1
  91. package/dist/engine/entity-handlers.js +17 -18
  92. package/dist/engine/entity-list-runtime-options.d.ts +5 -1
  93. package/dist/engine/entity-list-runtime-options.js +103 -4
  94. package/dist/engine/factories.d.ts +5 -5
  95. package/dist/engine/factories.js +3 -5
  96. package/dist/engine/field-access.d.ts +2 -0
  97. package/dist/engine/field-access.js +48 -1
  98. package/dist/engine/field-state.d.ts +12 -0
  99. package/dist/engine/field-state.js +67 -0
  100. package/dist/engine/handler-helpers.d.ts +3 -0
  101. package/dist/engine/handler-helpers.js +7 -1
  102. package/dist/engine/index.d.ts +2 -1
  103. package/dist/engine/index.js +2 -1
  104. package/dist/engine/ownership.d.ts +1 -1
  105. package/dist/engine/ownership.js +9 -5
  106. package/dist/engine/registry-state.d.ts +1 -0
  107. package/dist/engine/registry-state.js +1 -1
  108. package/dist/engine/schema-builder.js +3 -0
  109. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  110. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  111. package/dist/entrypoint/index.js +1 -0
  112. package/dist/entrypoint/internals.d.ts +2 -0
  113. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  114. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  115. package/dist/files/bind-pending-file-refs.js +11 -12
  116. package/dist/files/bind-referenced-files.write.js +5 -5
  117. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  118. package/dist/files/create-file-ref-from-bytes.js +2 -0
  119. package/dist/files/create-file-ref.d.ts +2 -0
  120. package/dist/files/create-file-ref.js +39 -22
  121. package/dist/files/dispose-removed-file-refs.js +5 -8
  122. package/dist/files/file-field-slots.d.ts +10 -0
  123. package/dist/files/file-field-slots.js +53 -0
  124. package/dist/files/file-ref-entity.d.ts +1 -1
  125. package/dist/files/file-ref-table.d.ts +1 -1
  126. package/dist/files/file-routes.js +30 -9
  127. package/dist/files/index.d.ts +1 -0
  128. package/dist/files/index.js +1 -0
  129. package/dist/files/load-save-file-refs.js +3 -4
  130. package/dist/files/record-read-check.js +2 -30
  131. package/dist/files/record-write-check.d.ts +2 -0
  132. package/dist/files/record-write-check.js +18 -3
  133. package/dist/files/upload-field-resolution.d.ts +4 -1
  134. package/dist/files/upload-field-resolution.js +57 -12
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-command.js +46 -12
  139. package/dist/schema-cli.js +1 -1
  140. package/dist/ui-types/index.d.ts +2 -1
  141. package/dist/ui-types/index.js +2 -1
  142. package/package.json +3 -3
  143. 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
+ ```