@cosmicdrift/kumiko-framework 0.363.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 +7 -0
- 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/routes.js +46 -17
- 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 +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- 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 +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- 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 +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -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 +2 -2
- 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 +8 -8
- 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 +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -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 +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- 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/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 +1 -1
- 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/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- 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-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- 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 +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/custom-fields-basic. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# custom-fields-basic
|
|
4
|
+
|
|
5
|
+
Tenant-defined extra fields on an entity without writing migrations.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Custom fields
|
|
10
|
+
|
|
11
|
+
Let a tenant add their own fields to your entity at runtime — without
|
|
12
|
+
writing a migration, without rebuilding your handler, without a code
|
|
13
|
+
change. The recipe wires a `property` entity into the `custom-fields`
|
|
14
|
+
bundle and drives a full *define → set → read* roundtrip.
|
|
15
|
+
|
|
16
|
+
The result: the tenant defines `internalNumber: text`, sets it on a
|
|
17
|
+
property, and reads it back flattened onto the row — looking exactly
|
|
18
|
+
like a first-class column.
|
|
19
|
+
|
|
20
|
+
## What it shows
|
|
21
|
+
|
|
22
|
+
- **`customFieldsField()`** — a jsonb column factory that holds the
|
|
23
|
+
per-row custom field values. Add it to any entity you want
|
|
24
|
+
custom-field-capable.
|
|
25
|
+
- **`wireCustomFieldsFor(r, entityName, table)`** — wires the
|
|
26
|
+
bundle's projection: a multi-stream projection consumes
|
|
27
|
+
`customField.set` / `.cleared` events and writes them into the
|
|
28
|
+
jsonb column, and an entity-postQuery hook flattens the jsonb onto
|
|
29
|
+
the row at read-time.
|
|
30
|
+
- **No migrations at runtime** — the schema for custom fields lives
|
|
31
|
+
in two bundled tables (`field-definition` for the spec, jsonb on
|
|
32
|
+
the host row for the values). Defining a field is a write, not a
|
|
33
|
+
DDL.
|
|
34
|
+
- **Stammfeld-look on the response** — reads return `{ id, name,
|
|
35
|
+
internalNumber: "X-2042" }`. The consumer can't tell which fields
|
|
36
|
+
are first-class and which are custom.
|
|
37
|
+
|
|
38
|
+
## Feature composition
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
custom-fields → core bundle: events + write-handlers + MSP +
|
|
42
|
+
field-definition entity
|
|
43
|
+
property-management → our feature: opts the `property` entity into
|
|
44
|
+
custom-fields via wireCustomFieldsFor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The `property-management` feature `r.requires("custom-fields")` — the
|
|
48
|
+
bundle is non-optional for this recipe because the wired entity would
|
|
49
|
+
otherwise have an empty jsonb column and no way to write into it.
|
|
50
|
+
|
|
51
|
+
## Flow
|
|
52
|
+
|
|
53
|
+
1. Tenant admin calls `custom-fields:write:define-tenant-field` to
|
|
54
|
+
declare `internalNumber: text` on `property`.
|
|
55
|
+
2. App code creates a `property` row via
|
|
56
|
+
`property-management:write:property:create`.
|
|
57
|
+
3. Tenant admin calls `custom-fields:write:set-custom-field` with the
|
|
58
|
+
`entityId`, `fieldKey: "internalNumber"`, `value: "X-2042"`.
|
|
59
|
+
4. The bundle's MSP consumes the `customField.set` event and writes
|
|
60
|
+
the value into the property row's `customFields` jsonb column.
|
|
61
|
+
5. The next `property-management:query:property:list` returns the row
|
|
62
|
+
with `internalNumber: "X-2042"` flattened onto the response — the
|
|
63
|
+
entity-postQuery hook merges the jsonb keys onto the row root.
|
|
64
|
+
|
|
65
|
+
## When to reach for it
|
|
66
|
+
|
|
67
|
+
You ship a SaaS where every tenant wants one or two of their own
|
|
68
|
+
columns — internal IDs, vendor names, custom flags — and you don't
|
|
69
|
+
want to either ship them all as `extraFields1..extraFields5` or push
|
|
70
|
+
the tenant to a request-a-feature queue. Custom fields cover the
|
|
71
|
+
single-or-handful-of-extra-fields case without engineering
|
|
72
|
+
involvement.
|
|
73
|
+
|
|
74
|
+
If you need the field to drive business logic (`if vipFlag then ...`),
|
|
75
|
+
make it a first-class field instead — see [`basic-entity`](../basic-entity/).
|
|
76
|
+
|
|
77
|
+
## Tests
|
|
78
|
+
|
|
79
|
+
The integration test under `src/__tests__/` walks two scenarios:
|
|
80
|
+
|
|
81
|
+
- Tenant defines a text field, sets a value, reads it back flat.
|
|
82
|
+
- Tenant defines a number field, sets it, reads it back with the
|
|
83
|
+
number type preserved.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bun kumiko test integration samples/custom-fields-basic
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Source
|
|
90
|
+
|
|
91
|
+
The feature is ~80 lines (`src/feature.ts`). The integration test is
|
|
92
|
+
~110 lines (`src/__tests__/feature.integration.test.ts`).
|
|
93
|
+
|
|
94
|
+
## Source files
|
|
95
|
+
|
|
96
|
+
### src/__tests__/feature.integration.test.ts
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// Custom-Fields Basic — integration test.
|
|
100
|
+
//
|
|
101
|
+
// Proves the full define → set → read roundtrip via the real dispatcher,
|
|
102
|
+
// MSP-pipeline and DB:
|
|
103
|
+
// 1. tenant defines `internalNumber` on `property`
|
|
104
|
+
// 2. property is created
|
|
105
|
+
// 3. customField is set → MSP writes into jsonb
|
|
106
|
+
// 4. list-query returns the value flattened onto the row
|
|
107
|
+
//
|
|
108
|
+
// This is the smallest evidence that wireCustomFieldsFor + the bundle's
|
|
109
|
+
// projection-hook + entity-postQuery flattening all line up.
|
|
110
|
+
|
|
111
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
112
|
+
import {
|
|
113
|
+
createCustomFieldsFeature,
|
|
114
|
+
fieldDefinitionEntity,
|
|
115
|
+
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
|
|
116
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
117
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
118
|
+
import {
|
|
119
|
+
createTestUser,
|
|
120
|
+
setupTestStack,
|
|
121
|
+
type TestStack,
|
|
122
|
+
unsafeCreateEntityTable,
|
|
123
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
124
|
+
import { propertyEntity, propertyFeature } from "../feature";
|
|
125
|
+
|
|
126
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
127
|
+
const customFields = createCustomFieldsFeature();
|
|
128
|
+
|
|
129
|
+
let stack: TestStack;
|
|
130
|
+
|
|
131
|
+
beforeAll(async () => {
|
|
132
|
+
stack = await setupTestStack({ features: [customFields, propertyFeature] });
|
|
133
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
134
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
135
|
+
await createEventsTable(stack.db);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterAll(async () => {
|
|
139
|
+
await stack.cleanup();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
beforeEach(async () => {
|
|
143
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
|
|
144
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_sample_cf_properties`);
|
|
145
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function defineField(fieldKey: string, type = "text") {
|
|
149
|
+
return stack.http.writeOk(
|
|
150
|
+
"custom-fields:write:define-tenant-field",
|
|
151
|
+
{
|
|
152
|
+
entityName: "property",
|
|
153
|
+
fieldKey,
|
|
154
|
+
serializedField: { type },
|
|
155
|
+
required: false,
|
|
156
|
+
searchable: false,
|
|
157
|
+
displayOrder: 0,
|
|
158
|
+
},
|
|
159
|
+
admin,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function setField(entityId: string, fieldKey: string, value: unknown) {
|
|
164
|
+
return stack.http.writeOk(
|
|
165
|
+
"custom-fields:write:set-custom-field",
|
|
166
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
167
|
+
admin,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function listProperties() {
|
|
172
|
+
return (await stack.http.queryOk("property-management:query:property:list", {}, admin)) as {
|
|
173
|
+
rows: Array<Record<string, unknown>>;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("custom-fields-basic recipe — define + set + read", () => {
|
|
178
|
+
test("tenant-defined field lands flat on the entity response", async () => {
|
|
179
|
+
const id = "11111111-1111-4000-8000-000000000001";
|
|
180
|
+
|
|
181
|
+
await defineField("internalNumber");
|
|
182
|
+
await stack.http.writeOk(
|
|
183
|
+
"property-management:write:property:create",
|
|
184
|
+
{ id, name: "Hofgarten 12" },
|
|
185
|
+
admin,
|
|
186
|
+
);
|
|
187
|
+
await setField(id, "internalNumber", "X-2042");
|
|
188
|
+
await stack.eventDispatcher?.runOnce();
|
|
189
|
+
|
|
190
|
+
const { rows } = await listProperties();
|
|
191
|
+
const property = rows.find((row) => row["id"] === id);
|
|
192
|
+
expect(property?.["internalNumber"]).toBe("X-2042");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("typed field: number value preserves its type", async () => {
|
|
196
|
+
const id = "22222222-2222-4000-8000-000000000002";
|
|
197
|
+
|
|
198
|
+
await defineField("tier", "number");
|
|
199
|
+
await stack.http.writeOk(
|
|
200
|
+
"property-management:write:property:create",
|
|
201
|
+
{ id, name: "MultiField" },
|
|
202
|
+
admin,
|
|
203
|
+
);
|
|
204
|
+
await setField(id, "tier", 2);
|
|
205
|
+
await stack.eventDispatcher?.runOnce();
|
|
206
|
+
|
|
207
|
+
const property = (await listProperties()).rows.find((row) => row["id"] === id);
|
|
208
|
+
expect(property?.["tier"]).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### src/feature.ts
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// kumiko-feature-version: 1
|
|
217
|
+
// Custom-Fields Basic Sample
|
|
218
|
+
//
|
|
219
|
+
// Shows how a feature opts a tenant-owned entity into the custom-fields
|
|
220
|
+
// extension: tenants can define their own fields at runtime, set values
|
|
221
|
+
// per row, and read them back flattened onto the entity — without writing
|
|
222
|
+
// a single migration or extra handler.
|
|
223
|
+
//
|
|
224
|
+
// Flow:
|
|
225
|
+
// 1. App-author defines a `property` entity and wires it via
|
|
226
|
+
// `wireCustomFieldsFor(r, "property", propertyTable)`.
|
|
227
|
+
// 2. A tenant admin defines a field at runtime
|
|
228
|
+
// (e.g. `internalNumber: text`) via the bundle's CRUD.
|
|
229
|
+
// 3. Values are written via `custom-fields:write:set-custom-field`,
|
|
230
|
+
// not through the host entity's own write handler.
|
|
231
|
+
// 4. Reads return the field flattened onto the row — looks like a
|
|
232
|
+
// first-class column. The flattening is an entity-level postQuery
|
|
233
|
+
// hook (registered by wireCustomFieldsFor), so it fires for ANY
|
|
234
|
+
// query whose name maps to this entity — including the hand-written
|
|
235
|
+
// `property:list` below.
|
|
236
|
+
|
|
237
|
+
import {
|
|
238
|
+
customFieldsField,
|
|
239
|
+
wireCustomFieldsFor,
|
|
240
|
+
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
|
|
241
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
242
|
+
import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
243
|
+
import { z } from "zod";
|
|
244
|
+
|
|
245
|
+
// --- Entity ---
|
|
246
|
+
//
|
|
247
|
+
// The field shape is declared as a plain object literal so the feature is
|
|
248
|
+
// self-describing Object-Form (what the AI/Designer should emit). The same
|
|
249
|
+
// literal is passed inline to `r.entity` below. `{ type: "jsonb" }` is
|
|
250
|
+
// exactly what `customFieldsField()` produces — the jsonb column the
|
|
251
|
+
// bundle's projection writes into; without it `wireCustomFieldsFor` has
|
|
252
|
+
// nowhere to land the values.
|
|
253
|
+
|
|
254
|
+
export const propertyEntity = createEntity({
|
|
255
|
+
table: "read_sample_cf_properties",
|
|
256
|
+
fields: {
|
|
257
|
+
name: { type: "text", required: true, maxLength: 200 },
|
|
258
|
+
customFields: customFieldsField(),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export const propertyTable = buildEntityTable("property", propertyEntity);
|
|
263
|
+
|
|
264
|
+
function propertyExecutor() {
|
|
265
|
+
return createEventStoreExecutor(propertyTable, propertyEntity, { entityName: "property" });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Feature ---
|
|
269
|
+
|
|
270
|
+
export const propertyFeature = defineFeature("property-management", (r) => {
|
|
271
|
+
r.requires("custom-fields");
|
|
272
|
+
|
|
273
|
+
r.entity("property", {
|
|
274
|
+
table: "read_sample_cf_properties",
|
|
275
|
+
fields: {
|
|
276
|
+
name: { type: "text", required: true, maxLength: 200 },
|
|
277
|
+
customFields: { type: "jsonb" },
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Opt this entity into the custom-fields extension. This registers the
|
|
282
|
+
// multi-stream projection (MSP) that consumes customField.set / .cleared
|
|
283
|
+
// events and writes them into the `customFields` jsonb column, plus the
|
|
284
|
+
// entity postQuery hook that flattens the jsonb onto the row at read-time.
|
|
285
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
286
|
+
|
|
287
|
+
r.writeHandler({
|
|
288
|
+
name: "property:create",
|
|
289
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
290
|
+
access: { roles: ["TenantAdmin"] },
|
|
291
|
+
handler: async (event, ctx) =>
|
|
292
|
+
propertyExecutor().create(
|
|
293
|
+
{ id: event.payload.id, name: event.payload.name, customFields: {} },
|
|
294
|
+
event.user,
|
|
295
|
+
ctx.db,
|
|
296
|
+
),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// List query. Named `property:list` so it maps to the `property` entity
|
|
300
|
+
// (colon convention) — that mapping is what lets the entity postQuery
|
|
301
|
+
// hook from wireCustomFieldsFor flatten customFields onto each row. The
|
|
302
|
+
// handler itself just reads the tenant-scoped read table; ctx.db is
|
|
303
|
+
// already tenant-scoped.
|
|
304
|
+
r.queryHandler({
|
|
305
|
+
name: "property:list",
|
|
306
|
+
schema: z.object({}),
|
|
307
|
+
access: { roles: ["TenantAdmin"] },
|
|
308
|
+
handler: async (_query, ctx) => {
|
|
309
|
+
const rows = await ctx.db.selectMany(propertyTable);
|
|
310
|
+
return { rows };
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
```
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/custom-handlers. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# custom-handlers
|
|
4
|
+
|
|
5
|
+
Beyond CRUD — when your domain refuses to fit a table.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Custom handlers
|
|
10
|
+
|
|
11
|
+
Replace the default CRUD with handler bodies that carry business rules.
|
|
12
|
+
This recipe ships a `counter` entity with three custom write handlers
|
|
13
|
+
(create, increment with read-modify-write, reset with last-writer-wins)
|
|
14
|
+
plus a custom query that filters the projection.
|
|
15
|
+
|
|
16
|
+
The point is to show the **boundary**: where the entity executor stops
|
|
17
|
+
doing work for you and where your code has to take over. The framework
|
|
18
|
+
gives you `createEntityExecutor` so the executor's `create` / `update` /
|
|
19
|
+
`detail` / `list` are still one-liners — only the surrounding business
|
|
20
|
+
logic is yours.
|
|
21
|
+
|
|
22
|
+
## What it shows
|
|
23
|
+
|
|
24
|
+
- **`r.writeHandler(name, schema, handler, options)`** — the positional
|
|
25
|
+
inline form, useful when the handler body is short and stays close to
|
|
26
|
+
its registration.
|
|
27
|
+
- **Read-modify-write with optimistic locking** — `increment` reads the
|
|
28
|
+
current count, computes the new one, and updates with the version it
|
|
29
|
+
read. Two concurrent increments cannot both succeed.
|
|
30
|
+
- **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
|
|
31
|
+
bypasses the version check because the operation is admin-driven and
|
|
32
|
+
resetting twice is semantically the same as resetting once.
|
|
33
|
+
- **Custom queries** — `counter:active` filters the projection in
|
|
34
|
+
TypeScript after the executor's list returns. Useful when the filter is
|
|
35
|
+
computed (above-threshold) rather than indexed.
|
|
36
|
+
- **`failNotFound` helper** — typed error response for the 404 case
|
|
37
|
+
without a hand-built `KumikoError` subclass.
|
|
38
|
+
|
|
39
|
+
## When to reach for it
|
|
40
|
+
|
|
41
|
+
You have an entity but the default CRUD doesn't capture what your
|
|
42
|
+
business rule actually does. The default `update` blindly writes the
|
|
43
|
+
payload; your `increment` needs to read first, decide, and write. That's
|
|
44
|
+
the line.
|
|
45
|
+
|
|
46
|
+
## Source
|
|
47
|
+
|
|
48
|
+
The whole feature lives in `src/feature.ts` (~100 lines). Integration
|
|
49
|
+
tests cover the optimistic-lock case, the skip-lock case, and the
|
|
50
|
+
custom-filter query.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bun kumiko test integration samples/custom-handlers
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Source files
|
|
57
|
+
|
|
58
|
+
### src/__tests__/feature.integration.test.ts
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Custom Handlers Sample — Integration Test
|
|
62
|
+
// Proves: custom business logic in handlers, payload transformation, custom queries
|
|
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
|
+
TestUsers,
|
|
72
|
+
unsafeCreateEntityTable,
|
|
73
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
74
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
75
|
+
import { counterEntity, counterFeature } from "../feature";
|
|
76
|
+
|
|
77
|
+
let stack: TestStack;
|
|
78
|
+
|
|
79
|
+
const admin = TestUsers.admin;
|
|
80
|
+
const user = createTestUser({ id: 2, roles: ["User"] });
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
stack = await setupTestStack({ features: [counterFeature] });
|
|
84
|
+
await unsafeCreateEntityTable(stack.db, counterEntity);
|
|
85
|
+
await createEventsTable(stack.db);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await stack.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
stack.events.reset();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("custom write handler: increment", () => {
|
|
97
|
+
test("increment adds to existing count", async () => {
|
|
98
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
99
|
+
"counters:write:counter:create",
|
|
100
|
+
{
|
|
101
|
+
name: "page-views",
|
|
102
|
+
},
|
|
103
|
+
admin,
|
|
104
|
+
);
|
|
105
|
+
expect(created.data["count"]).toBe(0);
|
|
106
|
+
|
|
107
|
+
const incremented = await stack.http.writeOk<SaveContext>(
|
|
108
|
+
"counters:write:counter:increment",
|
|
109
|
+
{
|
|
110
|
+
id: created.id,
|
|
111
|
+
amount: 5,
|
|
112
|
+
},
|
|
113
|
+
user,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(incremented.data["count"]).toBe(5);
|
|
117
|
+
expect(incremented.data["lastIncrementedBy"]).toBe(`user:${user.id}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("multiple increments accumulate", async () => {
|
|
121
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
122
|
+
"counters:write:counter:create",
|
|
123
|
+
{
|
|
124
|
+
name: "clicks",
|
|
125
|
+
},
|
|
126
|
+
admin,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await stack.http.writeOk<SaveContext>(
|
|
130
|
+
"counters:write:counter:increment",
|
|
131
|
+
{
|
|
132
|
+
id: created.id,
|
|
133
|
+
amount: 3,
|
|
134
|
+
},
|
|
135
|
+
user,
|
|
136
|
+
);
|
|
137
|
+
const result = await stack.http.writeOk<SaveContext>(
|
|
138
|
+
"counters:write:counter:increment",
|
|
139
|
+
{
|
|
140
|
+
id: created.id,
|
|
141
|
+
amount: 7,
|
|
142
|
+
},
|
|
143
|
+
user,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(result.data["count"]).toBe(10);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("increment validates max amount", async () => {
|
|
150
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
151
|
+
"counters:write:counter:create",
|
|
152
|
+
{
|
|
153
|
+
name: "guarded",
|
|
154
|
+
},
|
|
155
|
+
admin,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const error = await stack.http.writeErr(
|
|
159
|
+
"counters:write:counter:increment",
|
|
160
|
+
{
|
|
161
|
+
id: created.id,
|
|
162
|
+
amount: 101,
|
|
163
|
+
},
|
|
164
|
+
user,
|
|
165
|
+
);
|
|
166
|
+
expectErrorIncludes(error, "validation");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("increment on non-existent returns error", async () => {
|
|
170
|
+
const error = await stack.http.writeErr(
|
|
171
|
+
"counters:write:counter:increment",
|
|
172
|
+
{
|
|
173
|
+
id: "00000000-0000-4000-8000-000000000999",
|
|
174
|
+
amount: 1,
|
|
175
|
+
},
|
|
176
|
+
user,
|
|
177
|
+
);
|
|
178
|
+
expect(error.code).toBe("not_found");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("custom write handler: reset", () => {
|
|
183
|
+
test("reset sets count back to zero", async () => {
|
|
184
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
185
|
+
"counters:write:counter:create",
|
|
186
|
+
{
|
|
187
|
+
name: "resettable",
|
|
188
|
+
},
|
|
189
|
+
admin,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await stack.http.writeOk<SaveContext>(
|
|
193
|
+
"counters:write:counter:increment",
|
|
194
|
+
{
|
|
195
|
+
id: created.id,
|
|
196
|
+
amount: 42,
|
|
197
|
+
},
|
|
198
|
+
user,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const reset = await stack.http.writeOk<SaveContext>(
|
|
202
|
+
"counters:write:counter:reset",
|
|
203
|
+
{
|
|
204
|
+
id: created.id,
|
|
205
|
+
},
|
|
206
|
+
admin,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(reset.data["count"]).toBe(0);
|
|
210
|
+
expect(reset.data["lastIncrementedBy"]).toBe("");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("only Admin can reset", async () => {
|
|
214
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
215
|
+
"counters:write:counter:create",
|
|
216
|
+
{
|
|
217
|
+
name: "admin-only-reset",
|
|
218
|
+
},
|
|
219
|
+
admin,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const error = await stack.http.writeErr(
|
|
223
|
+
"counters:write:counter:reset",
|
|
224
|
+
{
|
|
225
|
+
id: created.id,
|
|
226
|
+
},
|
|
227
|
+
user,
|
|
228
|
+
);
|
|
229
|
+
expect(error.code).toBe("access_denied");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("custom query handler: active counters", () => {
|
|
234
|
+
test("filters counters by minimum count", async () => {
|
|
235
|
+
const c1 = await stack.http.writeOk<SaveContext>(
|
|
236
|
+
"counters:write:counter:create",
|
|
237
|
+
{ name: "active-a" },
|
|
238
|
+
admin,
|
|
239
|
+
);
|
|
240
|
+
const c2 = await stack.http.writeOk<SaveContext>(
|
|
241
|
+
"counters:write:counter:create",
|
|
242
|
+
{ name: "active-b" },
|
|
243
|
+
admin,
|
|
244
|
+
);
|
|
245
|
+
await stack.http.writeOk<SaveContext>(
|
|
246
|
+
"counters:write:counter:create",
|
|
247
|
+
{ name: "inactive" },
|
|
248
|
+
admin,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await stack.http.writeOk<SaveContext>(
|
|
252
|
+
"counters:write:counter:increment",
|
|
253
|
+
{ id: c1.id, amount: 10 },
|
|
254
|
+
user,
|
|
255
|
+
);
|
|
256
|
+
await stack.http.writeOk<SaveContext>(
|
|
257
|
+
"counters:write:counter:increment",
|
|
258
|
+
{ id: c2.id, amount: 5 },
|
|
259
|
+
user,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
263
|
+
"counters:query:counter:active",
|
|
264
|
+
{ minCount: 5 },
|
|
265
|
+
admin,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const names = result.rows.map((r) => r["name"]);
|
|
269
|
+
expect(names).toContain("active-a");
|
|
270
|
+
expect(names).toContain("active-b");
|
|
271
|
+
expect(names).not.toContain("inactive");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### src/feature.ts
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
// Custom Handlers Sample
|
|
280
|
+
// Shows: writeHandler with business logic, queryHandler with custom filtering,
|
|
281
|
+
// handler that modifies payload before DB write.
|
|
282
|
+
|
|
283
|
+
import {
|
|
284
|
+
createEntity,
|
|
285
|
+
createEntityExecutor,
|
|
286
|
+
createNumberField,
|
|
287
|
+
createTextField,
|
|
288
|
+
defineFeature,
|
|
289
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
290
|
+
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
291
|
+
import { z } from "zod";
|
|
292
|
+
|
|
293
|
+
export const counterEntity = createEntity({
|
|
294
|
+
table: "read_sample_counters",
|
|
295
|
+
fields: {
|
|
296
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
297
|
+
count: createNumberField({ default: 0 }),
|
|
298
|
+
lastIncrementedBy: createTextField({ personal: "ref" }),
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export const counterFeature = defineFeature("counters", (r) => {
|
|
303
|
+
r.entity("counter", counterEntity);
|
|
304
|
+
|
|
305
|
+
// createEntityExecutor bundles buildEntityTable + createEventStoreExecutor —
|
|
306
|
+
// the same pair every custom write-handler opens with. Collapses 3 lines
|
|
307
|
+
// + the { entityName } bookkeeping into one destructure.
|
|
308
|
+
const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
|
|
309
|
+
|
|
310
|
+
// Standard create
|
|
311
|
+
r.writeHandler(
|
|
312
|
+
"counter:create",
|
|
313
|
+
z.object({ name: z.string().min(1) }),
|
|
314
|
+
async (event, ctx) =>
|
|
315
|
+
counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
|
|
316
|
+
{ access: { roles: ["Admin"] } },
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Custom handler: increment with business logic
|
|
320
|
+
r.writeHandler(
|
|
321
|
+
"counter:increment",
|
|
322
|
+
z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
|
|
323
|
+
async (event, ctx) => {
|
|
324
|
+
const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
|
|
325
|
+
if (!current) {
|
|
326
|
+
return failNotFound("counter", event.payload.id);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const newCount = (current["count"] as number) + event.payload.amount;
|
|
330
|
+
// Read-modify-write on counter: use the version we just read so two
|
|
331
|
+
// concurrent increments don't clobber each other's changes.
|
|
332
|
+
return counterExecutor.update(
|
|
333
|
+
{
|
|
334
|
+
id: event.payload.id,
|
|
335
|
+
version: current["version"] as number,
|
|
336
|
+
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
|
|
337
|
+
},
|
|
338
|
+
event.user,
|
|
339
|
+
ctx.db,
|
|
340
|
+
);
|
|
341
|
+
},
|
|
342
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// Custom handler: reset to zero
|
|
346
|
+
r.writeHandler(
|
|
347
|
+
"counter:reset",
|
|
348
|
+
z.object({ id: z.uuid() }),
|
|
349
|
+
async (event, ctx) =>
|
|
350
|
+
// Admin reset: last-writer-wins is intentional — there's no useful
|
|
351
|
+
// concurrent-reset race to guard against.
|
|
352
|
+
counterExecutor.update(
|
|
353
|
+
{ id: event.payload.id, changes: { count: 0, lastIncrementedBy: "" } },
|
|
354
|
+
event.user,
|
|
355
|
+
ctx.db,
|
|
356
|
+
{ skipOptimisticLock: true },
|
|
357
|
+
),
|
|
358
|
+
{ access: { roles: ["Admin"] } },
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
// Custom query: only counters above a threshold
|
|
362
|
+
r.queryHandler(
|
|
363
|
+
"counter:active",
|
|
364
|
+
z.object({ minCount: z.number().default(1) }),
|
|
365
|
+
async (query, ctx) => {
|
|
366
|
+
const all = await counterExecutor.list({}, query.user, ctx.db);
|
|
367
|
+
return {
|
|
368
|
+
...all,
|
|
369
|
+
rows: all.rows.filter((r) => (r["count"] as number) >= query.payload.minCount),
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
access: {
|
|
374
|
+
openToAll: {
|
|
375
|
+
reason:
|
|
376
|
+
"any signed-in user may list active counters; the sample has no per-user counter ownership",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Standard detail
|
|
383
|
+
r.queryHandler(
|
|
384
|
+
"counter:detail",
|
|
385
|
+
z.object({ id: z.uuid() }),
|
|
386
|
+
async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
|
|
387
|
+
{
|
|
388
|
+
access: {
|
|
389
|
+
openToAll: {
|
|
390
|
+
reason:
|
|
391
|
+
"any signed-in user may read a counter by id; the sample has no per-user counter ownership",
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
```
|