@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -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 +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -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 +215 -0
- package/agents/examples/ledger-invoicing.md +236 -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 +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -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 +250 -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 +418 -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 +75 -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.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- 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 +4 -3
- package/dist/api/server.js +39 -10
- 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/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -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.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- 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 +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.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 +87 -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/query-refs.js +14 -2
- 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/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- 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 +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- 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/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- 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 +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- 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 +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -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/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -0,0 +1,315 @@
|
|
|
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 { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
117
|
+
import {
|
|
118
|
+
createTestUser,
|
|
119
|
+
setupTestStack,
|
|
120
|
+
type TestStack,
|
|
121
|
+
testTenantId,
|
|
122
|
+
unsafeCreateEntityTable,
|
|
123
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
124
|
+
import { propertyEntity, propertyFeature } from "../feature";
|
|
125
|
+
|
|
126
|
+
// Each test runs in its own tenant, so rows from other tests never show up.
|
|
127
|
+
let admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(1) });
|
|
128
|
+
let nextTenant = 1;
|
|
129
|
+
const customFields = createCustomFieldsFeature();
|
|
130
|
+
|
|
131
|
+
let stack: TestStack;
|
|
132
|
+
|
|
133
|
+
beforeAll(async () => {
|
|
134
|
+
stack = await setupTestStack({ features: [customFields, propertyFeature] });
|
|
135
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
136
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
137
|
+
await createEventsTable(stack.db);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
await stack.cleanup();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
nextTenant += 1;
|
|
146
|
+
admin = createTestUser({ roles: ["TenantAdmin"], tenantId: testTenantId(nextTenant) });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
async function defineField(fieldKey: string, type = "text") {
|
|
150
|
+
return stack.http.writeOk(
|
|
151
|
+
"custom-fields:write:define-tenant-field",
|
|
152
|
+
{
|
|
153
|
+
entityName: "property",
|
|
154
|
+
fieldKey,
|
|
155
|
+
serializedField: { type },
|
|
156
|
+
required: false,
|
|
157
|
+
searchable: false,
|
|
158
|
+
displayOrder: 0,
|
|
159
|
+
},
|
|
160
|
+
admin,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function setField(entityId: string, fieldKey: string, value: unknown) {
|
|
165
|
+
return stack.http.writeOk(
|
|
166
|
+
"custom-fields:write:set-custom-field",
|
|
167
|
+
{ entityName: "property", entityId, fieldKey, value },
|
|
168
|
+
admin,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function listProperties() {
|
|
173
|
+
return (await stack.http.queryOk("property-management:query:property:list", {}, admin)) as {
|
|
174
|
+
rows: Array<Record<string, unknown>>;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe("custom-fields-basic recipe — define + set + read", () => {
|
|
179
|
+
test("tenant-defined field lands flat on the entity response", async () => {
|
|
180
|
+
const id = "11111111-1111-4000-8000-000000000001";
|
|
181
|
+
|
|
182
|
+
await defineField("internalNumber");
|
|
183
|
+
await stack.http.writeOk(
|
|
184
|
+
"property-management:write:property:create",
|
|
185
|
+
{ id, name: "Hofgarten 12" },
|
|
186
|
+
admin,
|
|
187
|
+
);
|
|
188
|
+
await setField(id, "internalNumber", "X-2042");
|
|
189
|
+
await stack.eventDispatcher?.runOnce();
|
|
190
|
+
|
|
191
|
+
const { rows } = await listProperties();
|
|
192
|
+
const property = rows.find((row) => row["id"] === id);
|
|
193
|
+
expect(property?.["internalNumber"]).toBe("X-2042");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("typed field: number value preserves its type", async () => {
|
|
197
|
+
const id = "22222222-2222-4000-8000-000000000002";
|
|
198
|
+
|
|
199
|
+
await defineField("tier", "number");
|
|
200
|
+
await stack.http.writeOk(
|
|
201
|
+
"property-management:write:property:create",
|
|
202
|
+
{ id, name: "MultiField" },
|
|
203
|
+
admin,
|
|
204
|
+
);
|
|
205
|
+
await setField(id, "tier", 2);
|
|
206
|
+
await stack.eventDispatcher?.runOnce();
|
|
207
|
+
|
|
208
|
+
const property = (await listProperties()).rows.find((row) => row["id"] === id);
|
|
209
|
+
expect(property?.["tier"]).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### src/feature.ts
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// kumiko-feature-version: 1
|
|
218
|
+
// Custom-Fields Basic Sample
|
|
219
|
+
//
|
|
220
|
+
// Shows how a feature opts a tenant-owned entity into the custom-fields
|
|
221
|
+
// extension: tenants can define their own fields at runtime, set values
|
|
222
|
+
// per row, and read them back flattened onto the entity — without writing
|
|
223
|
+
// a single migration or extra handler.
|
|
224
|
+
//
|
|
225
|
+
// Flow:
|
|
226
|
+
// 1. App-author defines a `property` entity and wires it via
|
|
227
|
+
// `wireCustomFieldsFor(r, "property", propertyTable)`.
|
|
228
|
+
// 2. A tenant admin defines a field at runtime
|
|
229
|
+
// (e.g. `internalNumber: text`) via the bundle's CRUD.
|
|
230
|
+
// 3. Values are written via `custom-fields:write:set-custom-field`,
|
|
231
|
+
// not through the host entity's own write handler.
|
|
232
|
+
// 4. Reads return the field flattened onto the row — looks like a
|
|
233
|
+
// first-class column. The flattening is an entity-level postQuery
|
|
234
|
+
// hook (registered by wireCustomFieldsFor), so it fires for ANY
|
|
235
|
+
// query whose name maps to this entity — including the hand-written
|
|
236
|
+
// `property:list` below.
|
|
237
|
+
|
|
238
|
+
import {
|
|
239
|
+
customFieldsField,
|
|
240
|
+
wireCustomFieldsFor,
|
|
241
|
+
} from "@cosmicdrift/kumiko-bundled-features/custom-fields";
|
|
242
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
243
|
+
import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
244
|
+
import { z } from "zod";
|
|
245
|
+
|
|
246
|
+
// --- Entity ---
|
|
247
|
+
//
|
|
248
|
+
// The field shape is declared as a plain object literal so the feature is
|
|
249
|
+
// self-describing Object-Form (what the AI/Designer should emit). The same
|
|
250
|
+
// literal is passed inline to `r.entity` below. `{ type: "jsonb" }` is
|
|
251
|
+
// exactly what `customFieldsField()` produces — the jsonb column the
|
|
252
|
+
// bundle's projection writes into; without it `wireCustomFieldsFor` has
|
|
253
|
+
// nowhere to land the values.
|
|
254
|
+
|
|
255
|
+
export const propertyEntity = createEntity({
|
|
256
|
+
table: "read_sample_cf_properties",
|
|
257
|
+
fields: {
|
|
258
|
+
name: { type: "text", required: true, maxLength: 200 },
|
|
259
|
+
customFields: customFieldsField(),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
export const propertyTable = buildEntityTable("property", propertyEntity);
|
|
264
|
+
|
|
265
|
+
function propertyExecutor() {
|
|
266
|
+
return createEventStoreExecutor(propertyTable, propertyEntity, { entityName: "property" });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Feature ---
|
|
270
|
+
|
|
271
|
+
export const propertyFeature = defineFeature("property-management", (r) => {
|
|
272
|
+
r.requires("custom-fields");
|
|
273
|
+
|
|
274
|
+
r.entity("property", {
|
|
275
|
+
table: "read_sample_cf_properties",
|
|
276
|
+
fields: {
|
|
277
|
+
name: { type: "text", required: true, maxLength: 200 },
|
|
278
|
+
customFields: { type: "jsonb" },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Opt this entity into the custom-fields extension. This registers the
|
|
283
|
+
// multi-stream projection (MSP) that consumes customField.set / .cleared
|
|
284
|
+
// events and writes them into the `customFields` jsonb column, plus the
|
|
285
|
+
// entity postQuery hook that flattens the jsonb onto the row at read-time.
|
|
286
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
287
|
+
|
|
288
|
+
r.writeHandler({
|
|
289
|
+
name: "property:create",
|
|
290
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
291
|
+
access: { roles: ["TenantAdmin"] },
|
|
292
|
+
handler: async (event, ctx) =>
|
|
293
|
+
propertyExecutor().create(
|
|
294
|
+
{ id: event.payload.id, name: event.payload.name, customFields: {} },
|
|
295
|
+
event.user,
|
|
296
|
+
ctx.db,
|
|
297
|
+
),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// List query. Named `property:list` so it maps to the `property` entity
|
|
301
|
+
// (colon convention) — that mapping is what lets the entity postQuery
|
|
302
|
+
// hook from wireCustomFieldsFor flatten customFields onto each row. The
|
|
303
|
+
// handler itself just reads the tenant-scoped read table; ctx.db is
|
|
304
|
+
// already tenant-scoped.
|
|
305
|
+
r.queryHandler({
|
|
306
|
+
name: "property:list",
|
|
307
|
+
schema: z.object({}),
|
|
308
|
+
access: { roles: ["TenantAdmin"] },
|
|
309
|
+
handler: async (_query, ctx) => {
|
|
310
|
+
const rows = await ctx.db.selectMany(propertyTable);
|
|
311
|
+
return { rows };
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
```
|