@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,368 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/lifecycle-hooks. Do not edit. -->
2
+
3
+ # lifecycle-hooks
4
+
5
+ preSave, postSave, and the rest. Your logic, our plumbing.
6
+
7
+ ## README
8
+
9
+ # Lifecycle hooks
10
+
11
+ Attach behaviour to the four entity lifecycle moments: validation
12
+ (reject bad input), postSave (log every change), preDelete (block delete
13
+ when an invariant says no), postDelete (log after commit). This recipe
14
+ ships an `article` entity with hooks at each phase.
15
+
16
+ Hooks are how a feature inserts cross-cutting logic without the handler
17
+ body knowing about it. They run in the same transaction as the write
18
+ unless explicitly opted out, so a thrown hook rolls the whole change
19
+ back.
20
+
21
+ ## What it shows
22
+
23
+ - **`r.hook("validation", handlerRef, fn)`** — runs before the
24
+ handler body executes. Returns either `null` (pass) or an array of
25
+ `{ field, error }` objects (fail). Used here to reject titles
26
+ containing the word "spam" and titles longer than 200 characters.
27
+ - **`r.hook("postSave", { allOf: entityRef }, fn)`** — fires after every
28
+ successful save (create or update) in the same transaction.
29
+ `result.isNew` distinguishes create from update; `result.changes`
30
+ carries the changed fields only.
31
+ - **`r.hook("preDelete", { allOf: entityRef }, fn)`** — fires inside the
32
+ delete transaction. Throws on invariant violations (here: "published
33
+ articles cannot be deleted") to roll the delete back. The handler
34
+ receives the full row snapshot — no extra load needed.
35
+ - **`r.hook("postDelete", { allOf: entityRef }, fn)`** — fires after commit.
36
+ External side effects (logs, notifications) belong here because
37
+ failures cannot roll the delete back.
38
+
39
+ ## When to reach for it
40
+
41
+ You want logic that runs alongside writes — auditing, notification
42
+ emails, denormalised counter updates — without modifying the handler
43
+ body. The split between in-transaction and after-commit phases lets you
44
+ pick the right rollback semantics for each side effect.
45
+
46
+ ## Source
47
+
48
+ The whole feature lives in `src/feature.ts` (~100 lines). Integration
49
+ tests exercise each of the four hook phases plus the rollback path on
50
+ preDelete violation.
51
+
52
+ ```bash
53
+ bun kumiko test integration samples/lifecycle-hooks
54
+ ```
55
+
56
+ ## Source files
57
+
58
+ ### src/__tests__/feature.integration.test.ts
59
+
60
+ ```ts
61
+ // Lifecycle Hooks Sample — Integration Test
62
+ // Proves: validation rejects bad data, postSave tracks changes
63
+
64
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
65
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
66
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
67
+ import {
68
+ createTestUser,
69
+ setupTestStack,
70
+ type TestStack,
71
+ unsafeCreateEntityTable,
72
+ } from "@cosmicdrift/kumiko-framework/stack";
73
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
74
+ import { articleEntity, articlesFeature, hookLog } from "../feature";
75
+
76
+ let stack: TestStack;
77
+
78
+ const editor = createTestUser({ roles: ["Admin", "Editor"] });
79
+
80
+ beforeAll(async () => {
81
+ stack = await setupTestStack({ features: [articlesFeature] });
82
+ await unsafeCreateEntityTable(stack.db, articleEntity);
83
+ await createEventsTable(stack.db);
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ beforeEach(() => {
91
+ stack.events.reset();
92
+ hookLog.length = 0;
93
+ });
94
+
95
+ describe("validation hook: single handler", () => {
96
+ test("rejects title with banned word", async () => {
97
+ const error = await stack.http.writeErr(
98
+ "blog:write:article:create",
99
+ {
100
+ title: "Buy cheap SPAM now",
101
+ },
102
+ editor,
103
+ );
104
+
105
+ expectErrorIncludes(error, "title_contains_banned_word");
106
+ });
107
+
108
+ test("accepts clean title", async () => {
109
+ const data = await stack.http.writeOk<SaveContext>(
110
+ "blog:write:article:create",
111
+ {
112
+ title: "Clean Article",
113
+ },
114
+ editor,
115
+ );
116
+
117
+ expect(data.isNew).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("validation hook: multi-handler", () => {
122
+ test("rejects overly long title on create", async () => {
123
+ const error = await stack.http.writeErr(
124
+ "blog:write:article:create",
125
+ {
126
+ title: "X".repeat(201),
127
+ },
128
+ editor,
129
+ );
130
+
131
+ expectErrorIncludes(error, "title_too_long");
132
+ });
133
+
134
+ test("rejects overly long title on update", async () => {
135
+ const created = await stack.http.writeOk<SaveContext>(
136
+ "blog:write:article:create",
137
+ {
138
+ title: "Short",
139
+ },
140
+ editor,
141
+ );
142
+
143
+ const error = await stack.http.writeErr(
144
+ "blog:write:article:update",
145
+ {
146
+ id: created.id,
147
+ changes: { title: "Y".repeat(201) },
148
+ version: 1,
149
+ },
150
+ editor,
151
+ );
152
+
153
+ expectErrorIncludes(error, "title_too_long");
154
+ });
155
+ });
156
+
157
+ describe("postSave entity hook", () => {
158
+ test("logs create event", async () => {
159
+ await stack.http.writeOk<SaveContext>(
160
+ "blog:write:article:create",
161
+ {
162
+ title: "Hook Test",
163
+ },
164
+ editor,
165
+ );
166
+
167
+ expect(hookLog).toHaveLength(1);
168
+ expect(hookLog[0]?.type).toBe("created");
169
+ });
170
+
171
+ test("logs update event with changes", async () => {
172
+ const created = await stack.http.writeOk<SaveContext>(
173
+ "blog:write:article:create",
174
+ {
175
+ title: "Before Update",
176
+ },
177
+ editor,
178
+ );
179
+
180
+ hookLog.length = 0;
181
+
182
+ await stack.http.writeOk<SaveContext>(
183
+ "blog:write:article:update",
184
+ {
185
+ id: created.id,
186
+ changes: { title: "After Update" },
187
+ version: 1,
188
+ },
189
+ editor,
190
+ );
191
+
192
+ expect(hookLog).toHaveLength(1);
193
+ expect(hookLog[0]?.type).toBe("updated");
194
+ expect(hookLog[0]?.data["changes"]).toEqual({ title: "After Update" });
195
+ });
196
+ });
197
+
198
+ describe("preDelete + postDelete entity hooks", () => {
199
+ test("postDelete fires after a successful delete", async () => {
200
+ const created = await stack.http.writeOk<SaveContext>(
201
+ "blog:write:article:create",
202
+ { title: "Doomed" },
203
+ editor,
204
+ );
205
+
206
+ hookLog.length = 0;
207
+
208
+ await stack.http.writeOk<SaveContext>("blog:write:article:delete", { id: created.id }, editor);
209
+
210
+ expect(hookLog).toHaveLength(1);
211
+ expect(hookLog[0]?.type).toBe("deleted");
212
+ expect(hookLog[0]?.data["id"]).toBe(created.id);
213
+ });
214
+
215
+ test("preDelete blocks delete when the article is still published", async () => {
216
+ const created = await stack.http.writeOk<SaveContext>(
217
+ "blog:write:article:create",
218
+ { title: "Live Article" },
219
+ editor,
220
+ );
221
+ await stack.http.writeOk<SaveContext>(
222
+ "blog:write:article:update",
223
+ {
224
+ id: created.id,
225
+ changes: { isPublished: true },
226
+ version: 1,
227
+ },
228
+ editor,
229
+ );
230
+
231
+ hookLog.length = 0;
232
+
233
+ const error = await stack.http.writeErr(
234
+ "blog:write:article:delete",
235
+ { id: created.id },
236
+ editor,
237
+ );
238
+
239
+ expectErrorIncludes(error, "published_article_cannot_be_deleted");
240
+
241
+ // postDelete MUST NOT fire when preDelete rolled the tx back
242
+ expect(hookLog.filter((e) => e.type === "deleted")).toHaveLength(0);
243
+ });
244
+
245
+ test("preDelete allows delete when article is not published", async () => {
246
+ const created = await stack.http.writeOk<SaveContext>(
247
+ "blog:write:article:create",
248
+ { title: "Draft Article" },
249
+ editor,
250
+ );
251
+
252
+ hookLog.length = 0;
253
+
254
+ await stack.http.writeOk<SaveContext>("blog:write:article:delete", { id: created.id }, editor);
255
+
256
+ expect(hookLog.map((e) => e.type)).toEqual(["deleted"]);
257
+ });
258
+ });
259
+ ```
260
+
261
+ ### src/feature.ts
262
+
263
+ ```ts
264
+ // Lifecycle Hooks Sample
265
+ // Shows: validation hooks (reject bad data), postSave entity hooks (track changes),
266
+ // preDelete hooks (block delete on invariant violation), postDelete hooks
267
+ // (log deletions after commit).
268
+
269
+ import {
270
+ createBooleanField,
271
+ createEntity,
272
+ createTextField,
273
+ defineEntityCreateHandler,
274
+ defineEntityDeleteHandler,
275
+ defineEntityUpdateHandler,
276
+ defineFeature,
277
+ type SaveContext,
278
+ } from "@cosmicdrift/kumiko-framework/engine";
279
+ import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
280
+
281
+ export const articleEntity = createEntity({
282
+ table: "read_sample_articles",
283
+ fields: {
284
+ // maxLength high so zod lets long titles through — the validation hook
285
+ // below rejects titles > 200 so the sample can demonstrate the hook path.
286
+ title: createTextField({
287
+ personal: false,
288
+ reason: "is_business_data",
289
+ required: true,
290
+ maxLength: 1000,
291
+ }),
292
+ content: createTextField({ personal: false, reason: "is_business_data" }),
293
+ status: createTextField({ personal: false, reason: "is_business_data", default: "draft" }),
294
+ isPublished: createBooleanField({ default: false }),
295
+ },
296
+ });
297
+
298
+ // Collect hook events for testing
299
+ export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
300
+
301
+ const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
302
+
303
+ export const articlesFeature = defineFeature("blog", (r) => {
304
+ const article = r.entity("article", articleEntity);
305
+
306
+ const articleCreate = r.writeHandler(
307
+ defineEntityCreateHandler("article", articleEntity, editorWrite),
308
+ );
309
+ const articleUpdate = r.writeHandler(
310
+ defineEntityUpdateHandler("article", articleEntity, editorWrite),
311
+ );
312
+ r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
313
+
314
+ // --- Validation hook on create: reject banned words + length ---
315
+ r.hook("validation", articleCreate, (data) => {
316
+ const title = data["title"] as string;
317
+ if (title.toLowerCase().includes("spam")) {
318
+ return [{ field: "title", error: "title_contains_banned_word" }];
319
+ }
320
+ if (title.length > 200) {
321
+ return [{ field: "title", error: "title_too_long" }];
322
+ }
323
+ return null;
324
+ });
325
+
326
+ // --- Validation hook on update: length check on title changes ---
327
+ r.hook("validation", articleUpdate, (data) => {
328
+ const changes = data["changes"] as Record<string, unknown> | undefined;
329
+ const title = changes?.["title"] as string | undefined;
330
+ if (title && title.length > 200) {
331
+ return [{ field: "title", error: "title_too_long" }];
332
+ }
333
+ return null;
334
+ });
335
+
336
+ // --- postSave entity hook: log all saves ---
337
+ r.hook("postSave", { allOf: article }, async (result: SaveContext) => {
338
+ hookLog.push({
339
+ type: result.isNew ? "created" : "updated",
340
+ data: { id: result.id, changes: result.changes },
341
+ });
342
+ });
343
+
344
+ // --- preDelete entity hook: block delete when the article is still published.
345
+ // preDelete runs inside the write transaction and throws on violation, so
346
+ // the delete rolls back before any projection or event is written. The
347
+ // payload.data carries the full row snapshot taken just before delete —
348
+ // no extra load needed.
349
+ r.hook("preDelete", { allOf: article }, async (payload) => {
350
+ if (payload.data["isPublished"] === true) {
351
+ throw new ConflictError({
352
+ message: `article ${payload.id} is still published`,
353
+ i18nKey: "errors.publishedArticleCannotBeDeleted",
354
+ details: { reason: "published_article_cannot_be_deleted", entityId: payload.id },
355
+ });
356
+ }
357
+ });
358
+
359
+ // --- postDelete entity hook: log deletions after the transaction commits.
360
+ // Default phase is afterCommit so failures here can't roll back the delete.
361
+ r.hook("postDelete", { allOf: article }, async (payload) => {
362
+ hookLog.push({
363
+ type: "deleted",
364
+ data: { id: payload.id },
365
+ });
366
+ });
367
+ });
368
+ ```