@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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- 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
|
+
```
|