@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,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
|
+
```
|