@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,420 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/basic-entity. Do not edit. -->
2
+
3
+ # basic-entity
4
+
5
+ The simplest possible CRUD demo. About six lines of actual code.
6
+
7
+ ## README
8
+
9
+ # Basic entity
10
+
11
+ Wire one event-sourced aggregate end-to-end with the framework's built-in CRUD
12
+ helpers. The recipe ships a `task` entity with the standard verbs — create,
13
+ update, delete, restore, list, detail — via `r.crud`, plus
14
+ soft-delete enabled.
15
+
16
+ This is the smallest useful sample: it demonstrates the path from
17
+ `createEntity({ fields })` to a working API surface without writing any Zod
18
+ schemas or handler bodies by hand.
19
+
20
+ ## What it shows
21
+
22
+ - **`createEntity` with field factories** — typed text, boolean, number
23
+ fields with options like `required`, `sortable`, `softDelete`.
24
+ - **`r.crud`** — entity + standard handlers in one call; skip
25
+ verbs in `verbs` and register explicitly when access differs per verb.
26
+ - **Per-verb access rules** — different roles can create vs. update vs.
27
+ delete. Editor and User roles can write; only Admin can delete or restore.
28
+ - **Soft delete** — `softDelete: true` on the entity gives the executor an
29
+ `isDeleted` column and the `delete` handler flips it instead of dropping
30
+ the row. `restore` flips it back.
31
+
32
+ ## Feature composition
33
+
34
+ ```
35
+ task-management → single feature, single `task` entity, six CRUD handlers
36
+ ```
37
+
38
+ No bundled features required — this is the baseline before you add
39
+ `r.requires("tenant")`, auth, or cross-feature extensions.
40
+
41
+ ## Flow
42
+
43
+ 1. Define fields with `createEntity({ fields: { … } })`.
44
+ 2. Call `r.crud("task", taskEntity, { write, read, verbs })`.
45
+ 3. Client calls `task-management:write:task:create` → row + event appended.
46
+ 4. `delete` soft-flips `isDeleted`; `restore` flips back; `list` excludes
47
+ deleted rows by default.
48
+
49
+ ## When to reach for it
50
+
51
+ You're starting a new feature with a single entity and want CRUD without
52
+ inventing your own handlers. Replace any single line with an explicit
53
+ `r.writeHandler({ name, schema, handler })` when you outgrow the defaults
54
+ — see [custom-handlers](/en/samples/recipes-custom-handlers/) for that path.
55
+
56
+ ## Tests
57
+
58
+ ```bash
59
+ bun kumiko test integration samples/basic-entity
60
+ ```
61
+
62
+ Integration tests under `src/__tests__/` exercise list-with-sort,
63
+ soft-delete + restore, and per-verb access boundaries (Editor vs Admin).
64
+
65
+ ## Related samples
66
+
67
+ - [custom-handlers](/en/samples/recipes-custom-handlers/) — replace generated
68
+ CRUD helpers with explicit handlers.
69
+ - [field-access](/en/samples/recipes-field-access/) — per-field read/write
70
+ rules on top of the same entity pattern.
71
+ - [custom-fields-basic](/en/samples/recipes-custom-fields-basic/) — tenant-
72
+ defined extra columns without migrations.
73
+
74
+ ## Source files
75
+
76
+ ### src/__tests__/feature.integration.test.ts
77
+
78
+ ```ts
79
+ // Basic CRUD Sample — Integration Test
80
+ // Proves: create, read, update, delete, soft delete, optimistic locking, sort
81
+
82
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
83
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
84
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
85
+ import {
86
+ createTestUser,
87
+ setupTestStack,
88
+ type TestStack,
89
+ TestUsers,
90
+ unsafeCreateEntityTable,
91
+ } from "@cosmicdrift/kumiko-framework/stack";
92
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
93
+ import { taskEntity, taskFeature } from "../feature";
94
+
95
+ let stack: TestStack;
96
+
97
+ const admin = TestUsers.admin;
98
+ const user = createTestUser({ id: 2, roles: ["User"] });
99
+
100
+ beforeAll(async () => {
101
+ stack = await setupTestStack({ features: [taskFeature] });
102
+ await unsafeCreateEntityTable(stack.db, taskEntity);
103
+ await createEventsTable(stack.db);
104
+ });
105
+
106
+ afterAll(async () => {
107
+ await stack.cleanup();
108
+ });
109
+
110
+ beforeEach(() => {
111
+ stack.events.reset();
112
+ });
113
+
114
+ // --- Create + Read ---
115
+
116
+ describe("create and read", () => {
117
+ test("create returns SaveContext with isNew=true", async () => {
118
+ const data = await stack.http.writeOk<SaveContext>(
119
+ "tasks:write:task:create",
120
+ {
121
+ title: "Buy milk",
122
+ description: "From the store",
123
+ },
124
+ admin,
125
+ );
126
+
127
+ expect(data.isNew).toBe(true);
128
+ expect(data.id).toBeDefined();
129
+ expect(data.data["title"]).toBe("Buy milk");
130
+ expect(data.data["version"]).toBe(1);
131
+ });
132
+
133
+ test("detail returns created record", async () => {
134
+ const created = await stack.http.writeOk<SaveContext>(
135
+ "tasks:write:task:create",
136
+ {
137
+ title: "Read me back",
138
+ },
139
+ admin,
140
+ );
141
+
142
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
143
+ "tasks:query:task:detail",
144
+ { id: created.id },
145
+ admin,
146
+ );
147
+
148
+ expect(detail["title"]).toBe("Read me back");
149
+ expect(detail["version"]).toBe(1);
150
+ });
151
+
152
+ test("boolean default applied", async () => {
153
+ const created = await stack.http.writeOk<SaveContext>(
154
+ "tasks:write:task:create",
155
+ {
156
+ title: "Defaults test",
157
+ },
158
+ admin,
159
+ );
160
+
161
+ expect(created.data["isArchived"]).toBe(false);
162
+ });
163
+ });
164
+
165
+ // --- Update ---
166
+
167
+ describe("update", () => {
168
+ test("update returns changes and previous", async () => {
169
+ const created = await stack.http.writeOk<SaveContext>(
170
+ "tasks:write:task:create",
171
+ {
172
+ title: "Before",
173
+ status: "todo",
174
+ },
175
+ admin,
176
+ );
177
+
178
+ const updated = await stack.http.writeOk<SaveContext>(
179
+ "tasks:write:task:update",
180
+ {
181
+ id: created.id,
182
+ changes: { title: "After" },
183
+ version: 1,
184
+ },
185
+ user,
186
+ );
187
+
188
+ expect(updated.isNew).toBe(false);
189
+ expect(updated.changes).toEqual({ title: "After" });
190
+ expect(updated.previous["title"]).toBe("Before");
191
+ expect(updated.data["title"]).toBe("After");
192
+ expect(updated.data["status"]).toBe("todo"); // unchanged field preserved
193
+ });
194
+
195
+ test("version increments on update", async () => {
196
+ const created = await stack.http.writeOk<SaveContext>(
197
+ "tasks:write:task:create",
198
+ {
199
+ title: "Versioned",
200
+ },
201
+ admin,
202
+ );
203
+
204
+ const updated = await stack.http.writeOk<SaveContext>(
205
+ "tasks:write:task:update",
206
+ {
207
+ id: created.id,
208
+ changes: { title: "V2" },
209
+ version: 1,
210
+ },
211
+ admin,
212
+ );
213
+
214
+ expect(updated.data["version"]).toBe(2);
215
+ });
216
+ });
217
+
218
+ // --- Optimistic Locking ---
219
+
220
+ describe("optimistic locking", () => {
221
+ test("stale version causes version_conflict", async () => {
222
+ const created = await stack.http.writeOk<SaveContext>(
223
+ "tasks:write:task:create",
224
+ {
225
+ title: "Lock me",
226
+ },
227
+ admin,
228
+ );
229
+
230
+ // First update succeeds
231
+ await stack.http.writeOk<SaveContext>(
232
+ "tasks:write:task:update",
233
+ {
234
+ id: created.id,
235
+ version: 1,
236
+ changes: { title: "V2" },
237
+ },
238
+ admin,
239
+ );
240
+
241
+ // Second update with stale version=1 fails
242
+ const error = await stack.http.writeErr(
243
+ "tasks:write:task:update",
244
+ {
245
+ id: created.id,
246
+ version: 1,
247
+ changes: { title: "Stale" },
248
+ },
249
+ admin,
250
+ );
251
+
252
+ expectErrorIncludes(error, "version_conflict");
253
+ });
254
+ });
255
+
256
+ // --- Soft Delete ---
257
+
258
+ describe("soft delete", () => {
259
+ test("deleted record disappears from detail", async () => {
260
+ const created = await stack.http.writeOk<SaveContext>(
261
+ "tasks:write:task:create",
262
+ {
263
+ title: "Delete me",
264
+ },
265
+ admin,
266
+ );
267
+
268
+ await stack.http.writeOk<SaveContext>("tasks:write:task:delete", { id: created.id }, admin);
269
+
270
+ const detail = await stack.http.queryOk<null>(
271
+ "tasks:query:task:detail",
272
+ { id: created.id },
273
+ admin,
274
+ );
275
+ expect(detail).toBeNull();
276
+ });
277
+
278
+ test("deleted record disappears from list", async () => {
279
+ const created = await stack.http.writeOk<SaveContext>(
280
+ "tasks:write:task:create",
281
+ {
282
+ title: "Unique-Del-List-Check",
283
+ },
284
+ admin,
285
+ );
286
+
287
+ await stack.http.writeOk<SaveContext>("tasks:write:task:delete", { id: created.id }, admin);
288
+
289
+ const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
290
+ "tasks:query:task:list",
291
+ {},
292
+ admin,
293
+ );
294
+ expect(list.rows.some((r) => r["title"] === "Unique-Del-List-Check")).toBe(false);
295
+ });
296
+ });
297
+
298
+ // --- List + Sort ---
299
+
300
+ describe("list and sort", () => {
301
+ test("list returns rows", async () => {
302
+ await stack.http.writeOk<SaveContext>("tasks:write:task:create", { title: "List-A" }, admin);
303
+ await stack.http.writeOk<SaveContext>("tasks:write:task:create", { title: "List-B" }, admin);
304
+
305
+ const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
306
+ "tasks:query:task:list",
307
+ {},
308
+ admin,
309
+ );
310
+ expect(list.rows.length).toBeGreaterThanOrEqual(2);
311
+ });
312
+
313
+ test("sort by status ASC", async () => {
314
+ await stack.http.writeOk<SaveContext>(
315
+ "tasks:write:task:create",
316
+ { title: "Sort-Z", status: "z-last" },
317
+ admin,
318
+ );
319
+ await stack.http.writeOk<SaveContext>(
320
+ "tasks:write:task:create",
321
+ { title: "Sort-A", status: "a-first" },
322
+ admin,
323
+ );
324
+
325
+ const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
326
+ "tasks:query:task:list",
327
+ { sort: "status", sortDirection: "asc" },
328
+ admin,
329
+ );
330
+
331
+ const statuses = list.rows.map((r) => r["status"]).filter(Boolean) as string[];
332
+ const sorted = [...statuses].sort();
333
+ expect(statuses).toEqual(sorted);
334
+ });
335
+ });
336
+
337
+ // --- Access Control ---
338
+
339
+ describe("access control", () => {
340
+ test("User role can create", async () => {
341
+ const data = await stack.http.writeOk<SaveContext>(
342
+ "tasks:write:task:create",
343
+ {
344
+ title: "User task",
345
+ },
346
+ user,
347
+ );
348
+ expect(data.isNew).toBe(true);
349
+ });
350
+
351
+ test("User role cannot delete (Admin only)", async () => {
352
+ const created = await stack.http.writeOk<SaveContext>(
353
+ "tasks:write:task:create",
354
+ {
355
+ title: "Protected",
356
+ },
357
+ admin,
358
+ );
359
+
360
+ const error = await stack.http.writeErr(
361
+ "tasks:write:task:delete",
362
+ {
363
+ id: created.id,
364
+ },
365
+ user,
366
+ );
367
+ expect(error.code).toBe("access_denied");
368
+ });
369
+ });
370
+ ```
371
+
372
+ ### src/feature.ts
373
+
374
+ ```ts
375
+ // Basic Entity Sample
376
+ // Shows: how to wire one event-sourced aggregate end-to-end via r.crud.
377
+ // Custom logic or per-verb access? Skip a verb in `verbs` and register explicitly.
378
+
379
+ import {
380
+ createBooleanField,
381
+ createEntity,
382
+ createTextField,
383
+ defineEntityDeleteHandler,
384
+ defineEntityRestoreHandler,
385
+ defineFeature,
386
+ } from "@cosmicdrift/kumiko-framework/engine";
387
+
388
+ export const taskEntity = createEntity({
389
+ table: "read_sample_tasks",
390
+ fields: {
391
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
392
+ description: createTextField({ personal: false, reason: "is_business_data" }),
393
+ // sortable: true so the integration test can exercise list-with-sort.
394
+ status: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
395
+ isArchived: createBooleanField({ default: false }),
396
+ },
397
+ softDelete: true,
398
+ });
399
+
400
+ const editorWrite = { access: { roles: ["Admin", "User"] } } as const;
401
+ const adminWrite = { access: { roles: ["Admin"] } } as const;
402
+ const openRead = {
403
+ access: {
404
+ openToAll: {
405
+ reason:
406
+ "any signed-in user may list and view tasks; writes are still gated by the roles below",
407
+ },
408
+ },
409
+ } as const;
410
+
411
+ export const taskFeature = defineFeature("tasks", (r) => {
412
+ r.crud("task", taskEntity, {
413
+ write: editorWrite,
414
+ read: openRead,
415
+ verbs: { delete: false, restore: false },
416
+ });
417
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, adminWrite));
418
+ r.writeHandler(defineEntityRestoreHandler("task", taskEntity, adminWrite));
419
+ });
420
+ ```
@@ -0,0 +1,122 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/boot-check. Do not edit. -->
2
+
3
+ # boot-check
4
+
5
+ A feature declares its own mount-invariant via r.bootCheck.
6
+
7
+ ## README
8
+
9
+ # Boot check
10
+
11
+ Declare a feature's own mount-invariant with `r.bootCheck(...)` instead of
12
+ relying on framework-internal knowledge. The recipe reproduces the
13
+ prompt-store integration trap: a feature ships PII-annotated
14
+ fields but the companion feature that's supposed to govern user data was
15
+ never mounted, and nothing caught it — until now.
16
+
17
+ ## What it shows
18
+
19
+ - **`r.bootCheck(fn)`** — a check function that runs once at boot with a
20
+ ctx exposing every mounted feature. Throw to fail the boot with a clear,
21
+ feature-prefixed message.
22
+ - **Conditional invariants `r.requires` can't express** — the check only
23
+ fails when *this* feature's own shape demands it (has a PII field), not
24
+ unconditionally. A plain `r.requires("user-data-hook")` would fail even
25
+ for a prompt-store variant with no PII fields at all.
26
+ - **`validateBoot`** — the framework's boot-time validator, run directly
27
+ against a feature list. `bootCheck` has no DB dependency, so this is the
28
+ cheapest way to exercise it — no `setupTestStack` needed.
29
+
30
+ ## Feature composition
31
+
32
+ ```
33
+ prompt-store → entity with a PII field, declares the bootCheck
34
+ user-data-hook → companion feature the check requires when PII is present
35
+ ```
36
+
37
+ ## Flow
38
+
39
+ 1. `prompt-store` defines an entity with a `pii: true` field.
40
+ 2. It registers `r.bootCheck(({ features }) => { ... })`, closing over its
41
+ own field definitions to decide whether the invariant applies.
42
+ 3. At boot, `validateBoot` runs every registered check. If `prompt-store` is
43
+ mounted without `user-data-hook`, the check throws and boot fails with
44
+ `[Feature prompt-store] r.bootCheck failed: ...`.
45
+ 4. Mount `user-data-hook` alongside it, and the same check passes.
46
+
47
+ ## When to reach for it
48
+
49
+ You're writing a feature whose validity depends on *another* feature being
50
+ mounted, but only under a condition your feature alone can evaluate (e.g.
51
+ "only if I have PII fields", "only if I expose more than N screens"). If the
52
+ requirement is unconditional, `r.requires`/`r.optionalRequires` is simpler
53
+ and already covered by the boot validator.
54
+
55
+ ## Tests
56
+
57
+ ```bash
58
+ bun test samples/recipes/boot-check/src/__tests__/feature.test.ts
59
+ ```
60
+
61
+ Two cases: companion mounted → boot succeeds; companion missing → boot
62
+ fails with the feature-prefixed message.
63
+
64
+ ## Source files
65
+
66
+ ### src/__tests__/feature.test.ts
67
+
68
+ ```ts
69
+ // Boot-Check Sample — Test
70
+ // Proves: boot succeeds when the companion feature is mounted, fails with a
71
+ // clear, feature-prefixed message when it's missing.
72
+
73
+ import { describe, expect, test } from "bun:test";
74
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
75
+ import { promptStoreFeature, userDataHookFeature } from "../feature";
76
+
77
+ describe("prompt-store bootCheck", () => {
78
+ test("companion mounted → boot succeeds", () => {
79
+ expect(() => validateBoot([userDataHookFeature, promptStoreFeature])).not.toThrow();
80
+ });
81
+
82
+ test("companion missing → boot fails with feature-prefixed message", () => {
83
+ expect(() => validateBoot([promptStoreFeature])).toThrow(
84
+ "[Feature prompt-store] r.bootCheck failed: prompt-store has PII fields but no user-data-hook feature is mounted",
85
+ );
86
+ });
87
+ });
88
+ ```
89
+
90
+ ### src/feature.ts
91
+
92
+ ```ts
93
+ // Boot-Check Sample
94
+ // Shows: how a feature declares its own mount-invariant via r.bootCheck,
95
+ // for conditional cross-feature requirements r.requires can't express.
96
+
97
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
98
+
99
+ // A prompt-store integration trap: a feature with a PII field was mounted
100
+ // without its required companion feature, and nothing caught it at boot.
101
+ // r.requires("user-data-hook") can't express this — it would
102
+ // fail even for a prompt-store variant with no PII fields at all.
103
+ const promptFields = {
104
+ text: createTextField({
105
+ personal: "self",
106
+ find: "none",
107
+ }),
108
+ };
109
+
110
+ export const promptStoreFeature = defineFeature("prompt-store", (r) => {
111
+ r.entity("prompt", createEntity({ fields: promptFields }));
112
+ r.bootCheck(({ features }) => {
113
+ const hasPiiField = Object.values(promptFields).some((field) => field.pii);
114
+ const hasUserDataHook = features.some((f) => f.name === "user-data-hook");
115
+ if (hasPiiField && !hasUserDataHook) {
116
+ throw new Error("prompt-store has PII fields but no user-data-hook feature is mounted");
117
+ }
118
+ });
119
+ });
120
+
121
+ export const userDataHookFeature = defineFeature("user-data-hook", () => {});
122
+ ```