@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,649 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/embedded. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# embedded
|
|
4
|
+
|
|
5
|
+
Embedded fields on entities. Structured JSON without the JSON part.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Embedded fields and lists
|
|
10
|
+
|
|
11
|
+
Keep owned, fixed-shape data with its parent entity instead of giving every
|
|
12
|
+
nested value its own aggregate. This recipe covers both a single embedded
|
|
13
|
+
object and an embedded list rendered by a declarative `entityEdit` screen.
|
|
14
|
+
|
|
15
|
+
## What it shows
|
|
16
|
+
|
|
17
|
+
- **Embedded objects** — `address` and optional `billingAddress` are validated
|
|
18
|
+
sub-schemas stored in the contact row.
|
|
19
|
+
- **Embedded lists** — invoice `lines` repeat a fixed row shape without their
|
|
20
|
+
own identity or event stream.
|
|
21
|
+
- **Structured cells** — a line uses a reference, select, number, and money
|
|
22
|
+
cell, with `amount` derived from `quantity × unitPrice` and a totals row.
|
|
23
|
+
- **Server-authoritative derived cells** — the write schema recomputes
|
|
24
|
+
`derived` values before validation, so a client cannot supply a stale amount;
|
|
25
|
+
the edit view model exposes the definitions as `embeddedListDerived`.
|
|
26
|
+
- **Declarative editing** — the `invoice-edit` screen uses
|
|
27
|
+
`createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
|
|
28
|
+
custom screen component is required.
|
|
29
|
+
|
|
30
|
+
## When to reach for it
|
|
31
|
+
|
|
32
|
+
Use an embedded field or list when the nested data is created, changed, and
|
|
33
|
+
deleted with its parent. Promote a row to its own entity when it needs an
|
|
34
|
+
independent status, history, or handler.
|
|
35
|
+
|
|
36
|
+
## Source
|
|
37
|
+
|
|
38
|
+
The feature entry point is `src/feature.ts`. The embedded object and invoice
|
|
39
|
+
line-item schemas live under `src/entities/`; integration tests cover schema
|
|
40
|
+
validation, derived values, totals, references, and the declarative edit
|
|
41
|
+
screen.
|
|
42
|
+
|
|
43
|
+
## Source files
|
|
44
|
+
|
|
45
|
+
### src/__tests__/feature.integration.test.ts
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// Embedded Object Sample — Integration Test
|
|
49
|
+
// Proves: embedded objects stored as JSONB, read back correctly,
|
|
50
|
+
// required embedded fails when missing, optional embedded can be omitted,
|
|
51
|
+
// searchable sub-fields registered, field access on sub-fields E2E
|
|
52
|
+
|
|
53
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
54
|
+
import type { EntityId, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
55
|
+
import {
|
|
56
|
+
createTestUser,
|
|
57
|
+
setupTestStack,
|
|
58
|
+
type TestStack,
|
|
59
|
+
TestUsers,
|
|
60
|
+
unsafeCreateEntityTable,
|
|
61
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
62
|
+
import { contactEntity } from "../entities/contact";
|
|
63
|
+
import { invoiceEntity } from "../entities/invoice";
|
|
64
|
+
import { productEntity } from "../entities/product";
|
|
65
|
+
import { embeddedFeature } from "../feature";
|
|
66
|
+
|
|
67
|
+
let stack: TestStack;
|
|
68
|
+
|
|
69
|
+
const admin = TestUsers.admin;
|
|
70
|
+
const viewer = createTestUser({ id: 2, roles: ["Viewer"] });
|
|
71
|
+
const otherTenant = createTestUser({ id: 3, tenantId: "00000000-0000-4000-8000-000000000002" });
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
stack = await setupTestStack({
|
|
75
|
+
features: [embeddedFeature],
|
|
76
|
+
systemHooks: [],
|
|
77
|
+
});
|
|
78
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
79
|
+
await unsafeCreateEntityTable(stack.db, productEntity);
|
|
80
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await stack.cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// --- Create + Read ---
|
|
88
|
+
|
|
89
|
+
describe("create and read embedded objects", () => {
|
|
90
|
+
test("create contact with required address succeeds", async () => {
|
|
91
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
92
|
+
"contacts:write:contact:create",
|
|
93
|
+
{
|
|
94
|
+
name: "Max Mustermann",
|
|
95
|
+
email: "max@test.de",
|
|
96
|
+
address: { street: "Hauptstr. 1", zip: "10115", city: "Berlin", country: "DE" },
|
|
97
|
+
},
|
|
98
|
+
admin,
|
|
99
|
+
);
|
|
100
|
+
expect(data.isNew).toBe(true);
|
|
101
|
+
expect(data.data["address"]).toEqual({
|
|
102
|
+
street: "Hauptstr. 1",
|
|
103
|
+
zip: "10115",
|
|
104
|
+
city: "Berlin",
|
|
105
|
+
country: "DE",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("detail returns embedded object intact", async () => {
|
|
110
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
111
|
+
"contacts:write:contact:create",
|
|
112
|
+
{
|
|
113
|
+
name: "Detail Test",
|
|
114
|
+
address: { street: "Musterweg 5", zip: "80331", city: "München" },
|
|
115
|
+
},
|
|
116
|
+
admin,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
120
|
+
"contacts:query:contact:detail",
|
|
121
|
+
{ id: created.id },
|
|
122
|
+
admin,
|
|
123
|
+
);
|
|
124
|
+
expect(detail["name"]).toBe("Detail Test");
|
|
125
|
+
const addr = detail["address"] as Record<string, unknown>;
|
|
126
|
+
expect(addr["street"]).toBe("Musterweg 5");
|
|
127
|
+
expect(addr["city"]).toBe("München");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("create with optional billingAddress", async () => {
|
|
131
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
132
|
+
"contacts:write:contact:create",
|
|
133
|
+
{
|
|
134
|
+
name: "With Billing",
|
|
135
|
+
address: { street: "A-Str.", zip: "12345", city: "Hamburg" },
|
|
136
|
+
billingAddress: {
|
|
137
|
+
street: "B-Str.",
|
|
138
|
+
zip: "54321",
|
|
139
|
+
city: "Frankfurt",
|
|
140
|
+
vatId: "DE123456789",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
admin,
|
|
144
|
+
);
|
|
145
|
+
expect(data.isNew).toBe(true);
|
|
146
|
+
const billing = data.data["billingAddress"] as Record<string, unknown>;
|
|
147
|
+
expect(billing["vatId"]).toBe("DE123456789");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("create without optional billingAddress succeeds", async () => {
|
|
151
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
152
|
+
"contacts:write:contact:create",
|
|
153
|
+
{
|
|
154
|
+
name: "No Billing",
|
|
155
|
+
address: { street: "C-Str.", zip: "11111", city: "Köln" },
|
|
156
|
+
},
|
|
157
|
+
admin,
|
|
158
|
+
);
|
|
159
|
+
expect(data.isNew).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// --- Validation ---
|
|
164
|
+
|
|
165
|
+
describe("embedded validation", () => {
|
|
166
|
+
test("missing required address fails", async () => {
|
|
167
|
+
const error = await stack.http.writeErr(
|
|
168
|
+
"contacts:write:contact:create",
|
|
169
|
+
{ name: "No Address" },
|
|
170
|
+
admin,
|
|
171
|
+
);
|
|
172
|
+
expect(error).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("address with missing required sub-field fails", async () => {
|
|
176
|
+
const error = await stack.http.writeErr(
|
|
177
|
+
"contacts:write:contact:create",
|
|
178
|
+
{
|
|
179
|
+
name: "Bad Address",
|
|
180
|
+
address: { street: "Only Street" }, // missing zip + city
|
|
181
|
+
},
|
|
182
|
+
admin,
|
|
183
|
+
);
|
|
184
|
+
expect(error).toBeDefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// --- Field access on embedded sub-fields (E2E via API) ---
|
|
189
|
+
|
|
190
|
+
describe("field access on embedded sub-fields", () => {
|
|
191
|
+
let contactWithBillingId: EntityId;
|
|
192
|
+
|
|
193
|
+
test("create contact with billingAddress for access tests", async () => {
|
|
194
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
195
|
+
"contacts:write:contact:create",
|
|
196
|
+
{
|
|
197
|
+
name: "Access Test Contact",
|
|
198
|
+
address: { street: "A", zip: "1", city: "X" },
|
|
199
|
+
billingAddress: { street: "B", zip: "2", city: "Y", vatId: "DE999" },
|
|
200
|
+
},
|
|
201
|
+
admin,
|
|
202
|
+
);
|
|
203
|
+
contactWithBillingId = created.id;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("Admin sees billingAddress.vatId via detail query", async () => {
|
|
207
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
208
|
+
"contacts:query:contact:detail",
|
|
209
|
+
{ id: contactWithBillingId },
|
|
210
|
+
admin,
|
|
211
|
+
);
|
|
212
|
+
const billing = detail["billingAddress"] as Record<string, unknown>;
|
|
213
|
+
expect(billing["vatId"]).toBe("DE999");
|
|
214
|
+
expect(billing["street"]).toBe("B");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("Viewer does NOT see billingAddress.vatId via detail query", async () => {
|
|
218
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
219
|
+
"contacts:query:contact:detail",
|
|
220
|
+
{ id: contactWithBillingId },
|
|
221
|
+
viewer,
|
|
222
|
+
);
|
|
223
|
+
const billing = detail["billingAddress"] as Record<string, unknown>;
|
|
224
|
+
expect(billing["vatId"]).toBeUndefined();
|
|
225
|
+
expect(billing["street"]).toBe("B"); // other fields still visible
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// --- Embedded list (createEmbeddedListField) ---
|
|
230
|
+
|
|
231
|
+
describe("embedded list of like-shaped rows", () => {
|
|
232
|
+
const withPhones = {
|
|
233
|
+
name: "Phone Book",
|
|
234
|
+
address: { street: "D-Str.", zip: "22222", city: "Bremen" },
|
|
235
|
+
phoneNumbers: [
|
|
236
|
+
{ label: "Buero", number: "+49 30 1", note: "Durchwahl 12" },
|
|
237
|
+
{ label: "Mobil", number: "+49 170 2" },
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
test("rows round-trip as an array, in order", async () => {
|
|
242
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
243
|
+
"contacts:write:contact:create",
|
|
244
|
+
withPhones,
|
|
245
|
+
admin,
|
|
246
|
+
);
|
|
247
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
248
|
+
"contacts:query:contact:detail",
|
|
249
|
+
{ id: created.id },
|
|
250
|
+
admin,
|
|
251
|
+
);
|
|
252
|
+
expect(detail["phoneNumbers"]).toEqual(withPhones.phoneNumbers);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("an omitted list reads back as an empty array (column default)", async () => {
|
|
256
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
257
|
+
"contacts:write:contact:create",
|
|
258
|
+
{ name: "No Phones", address: { street: "E-Str.", zip: "33333", city: "Essen" } },
|
|
259
|
+
admin,
|
|
260
|
+
);
|
|
261
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
262
|
+
"contacts:query:contact:detail",
|
|
263
|
+
{ id: created.id },
|
|
264
|
+
admin,
|
|
265
|
+
);
|
|
266
|
+
expect(detail["phoneNumbers"]).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("a row missing a required sub-field is rejected", async () => {
|
|
270
|
+
const error = await stack.http.writeErr(
|
|
271
|
+
"contacts:write:contact:create",
|
|
272
|
+
{
|
|
273
|
+
name: "Bad Row",
|
|
274
|
+
address: { street: "F-Str.", zip: "44444", city: "Kiel" },
|
|
275
|
+
phoneNumbers: [{ label: "Buero", number: "+49 30 1" }, { label: "Mobil" }],
|
|
276
|
+
},
|
|
277
|
+
admin,
|
|
278
|
+
);
|
|
279
|
+
expect(error).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("field access is applied per row, the value stays an array", async () => {
|
|
283
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
284
|
+
"contacts:write:contact:create",
|
|
285
|
+
withPhones,
|
|
286
|
+
admin,
|
|
287
|
+
);
|
|
288
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
289
|
+
"contacts:query:contact:detail",
|
|
290
|
+
{ id: created.id },
|
|
291
|
+
viewer,
|
|
292
|
+
);
|
|
293
|
+
expect(detail["phoneNumbers"]).toEqual([
|
|
294
|
+
{ label: "Buero", number: "+49 30 1" },
|
|
295
|
+
{ label: "Mobil", number: "+49 170 2" },
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// --- Searchable embedded sub-fields (registry) ---
|
|
301
|
+
|
|
302
|
+
describe("searchable embedded sub-fields", () => {
|
|
303
|
+
test("registry reports embedded sub-fields as searchable", () => {
|
|
304
|
+
const searchable = stack.registry.getSearchableFields("contact");
|
|
305
|
+
expect(searchable).toContain("name");
|
|
306
|
+
expect(searchable).toContain("address_street");
|
|
307
|
+
expect(searchable).toContain("address_city");
|
|
308
|
+
expect(searchable).not.toContain("address_zip"); // not marked searchable
|
|
309
|
+
expect(searchable).not.toContain("address_country");
|
|
310
|
+
expect(searchable).toContain("phoneNumbers_label");
|
|
311
|
+
expect(searchable).not.toContain("phoneNumbers_number");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// --- Tenant isolation ---
|
|
316
|
+
|
|
317
|
+
describe("tenant isolation", () => {
|
|
318
|
+
test("other tenant cannot read contact", async () => {
|
|
319
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
320
|
+
"contacts:write:contact:create",
|
|
321
|
+
{
|
|
322
|
+
name: "Secret Contact",
|
|
323
|
+
address: { street: "Hidden", zip: "00000", city: "Nowhere" },
|
|
324
|
+
},
|
|
325
|
+
admin,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const detail = await stack.http.queryOk<null>(
|
|
329
|
+
"contacts:query:contact:detail",
|
|
330
|
+
{ id: created.id },
|
|
331
|
+
otherTenant,
|
|
332
|
+
);
|
|
333
|
+
expect(detail).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// --- Invoice line items: select/reference/derived/totals metadata (#1835) ---
|
|
338
|
+
|
|
339
|
+
describe("invoice line items with select/reference/derived/totals metadata", () => {
|
|
340
|
+
const validLine = {
|
|
341
|
+
product: "00000000-0000-4000-8000-000000000001",
|
|
342
|
+
unit: "pcs",
|
|
343
|
+
quantity: 2,
|
|
344
|
+
unitPrice: 500,
|
|
345
|
+
amount: 1000,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
test("valid lines round-trip, including the client-computed amount", async () => {
|
|
349
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
350
|
+
"contacts:write:invoice:create",
|
|
351
|
+
{ customer: "Acme Inc.", lines: [validLine] },
|
|
352
|
+
admin,
|
|
353
|
+
);
|
|
354
|
+
expect(created.data["lines"]).toEqual([validLine]);
|
|
355
|
+
|
|
356
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
357
|
+
"contacts:query:invoice:detail",
|
|
358
|
+
{ id: created.id },
|
|
359
|
+
admin,
|
|
360
|
+
);
|
|
361
|
+
expect(detail["lines"]).toEqual([validLine]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("a line with a unit outside the select options is rejected", async () => {
|
|
365
|
+
const error = await stack.http.writeErr(
|
|
366
|
+
"contacts:write:invoice:create",
|
|
367
|
+
{ customer: "Bad Unit", lines: [{ ...validLine, unit: "liters" }] },
|
|
368
|
+
admin,
|
|
369
|
+
);
|
|
370
|
+
expect(error).toBeDefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a line with a non-UUID product reference is rejected", async () => {
|
|
374
|
+
const error = await stack.http.writeErr(
|
|
375
|
+
"contacts:write:invoice:create",
|
|
376
|
+
{ customer: "Bad Product", lines: [{ ...validLine, product: "not-a-uuid" }] },
|
|
377
|
+
admin,
|
|
378
|
+
);
|
|
379
|
+
expect(error).toBeDefined();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("an empty lines array violates minItems and is rejected", async () => {
|
|
383
|
+
const error = await stack.http.writeErr(
|
|
384
|
+
"contacts:write:invoice:create",
|
|
385
|
+
{ customer: "No Lines", lines: [] },
|
|
386
|
+
admin,
|
|
387
|
+
);
|
|
388
|
+
expect(error).toBeDefined();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("more than maxItems lines is rejected", async () => {
|
|
392
|
+
const tooManyLines = Array.from({ length: 6 }, () => ({ ...validLine }));
|
|
393
|
+
const error = await stack.http.writeErr(
|
|
394
|
+
"contacts:write:invoice:create",
|
|
395
|
+
{ customer: "Too Many Lines", lines: tooManyLines },
|
|
396
|
+
admin,
|
|
397
|
+
);
|
|
398
|
+
expect(error).toBeDefined();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### src/entities/contact.ts
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// Contact with embedded address — address belongs 1:1 to the contact,
|
|
407
|
+
// is never shared, and is always read/written together. `phoneNumbers` is the
|
|
408
|
+
// same idea for N rows: written whole with the contact, no per-row history.
|
|
409
|
+
|
|
410
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
411
|
+
import {
|
|
412
|
+
createEmbeddedField,
|
|
413
|
+
createEmbeddedListField,
|
|
414
|
+
createEntity,
|
|
415
|
+
createTextField,
|
|
416
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
417
|
+
|
|
418
|
+
export const contactEntity = createEntity({
|
|
419
|
+
table: "read_sample_contacts",
|
|
420
|
+
fields: {
|
|
421
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
|
|
422
|
+
email: createTextField({ format: "email", personal: "self", find: "none" }),
|
|
423
|
+
address: createEmbeddedField(
|
|
424
|
+
{
|
|
425
|
+
street: { type: "text", required: true, searchable: true },
|
|
426
|
+
zip: { type: "text", required: true },
|
|
427
|
+
city: { type: "text", required: true, searchable: true },
|
|
428
|
+
country: { type: "text" },
|
|
429
|
+
},
|
|
430
|
+
{ required: true },
|
|
431
|
+
),
|
|
432
|
+
billingAddress: createEmbeddedField({
|
|
433
|
+
street: { type: "text", required: true },
|
|
434
|
+
zip: { type: "text", required: true },
|
|
435
|
+
city: { type: "text", required: true },
|
|
436
|
+
country: { type: "text" },
|
|
437
|
+
vatId: { type: "text", access: { read: ["Admin", "Accounting"] } },
|
|
438
|
+
}),
|
|
439
|
+
// A list of like-shaped rows. A phone number has no lifetime of its own —
|
|
440
|
+
// editing the contact rewrites the whole list. Something that starts and
|
|
441
|
+
// ends on its own (a contract position, a subscription) would be its own
|
|
442
|
+
// entity referencing the contact instead.
|
|
443
|
+
phoneNumbers: createEmbeddedListField({
|
|
444
|
+
label: { type: "text", required: true, searchable: true },
|
|
445
|
+
number: { type: "text", required: true },
|
|
446
|
+
note: { type: "text", access: { read: ["Admin"] } },
|
|
447
|
+
}),
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
export const contactTable = buildEntityTable("contact", contactEntity);
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### src/entities/invoice.ts
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
458
|
+
import {
|
|
459
|
+
createEmbeddedListField,
|
|
460
|
+
createEntity,
|
|
461
|
+
createTextField,
|
|
462
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
463
|
+
|
|
464
|
+
// Invoice line-items — the motivating case for select/reference/derived/
|
|
465
|
+
// totals metadata on createEmbeddedListField: each line references a
|
|
466
|
+
// product, has a unit (select), and a derived amount (qty × unitPrice)
|
|
467
|
+
// that the entity sums across all lines.
|
|
468
|
+
export const invoiceEntity = createEntity({
|
|
469
|
+
table: "read_sample_invoices",
|
|
470
|
+
fields: {
|
|
471
|
+
customer: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
472
|
+
lines: createEmbeddedListField(
|
|
473
|
+
{
|
|
474
|
+
product: { type: "reference", entity: "product", required: true },
|
|
475
|
+
unit: { type: "select", options: ["pcs", "hours", "kg"], required: true },
|
|
476
|
+
quantity: { type: "number", required: true },
|
|
477
|
+
unitPrice: { type: "money", required: true },
|
|
478
|
+
amount: { type: "money" },
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
required: true,
|
|
482
|
+
minItems: 1,
|
|
483
|
+
maxItems: 5,
|
|
484
|
+
derived: { amount: { op: "multiply", from: ["quantity", "unitPrice"] } },
|
|
485
|
+
totals: ["amount"],
|
|
486
|
+
},
|
|
487
|
+
),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### src/entities/product.ts
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// A minimal entity for the invoice's `product` reference cell — just
|
|
498
|
+
// enough to demonstrate a reference sub-field on an embedded list.
|
|
499
|
+
|
|
500
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
501
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
502
|
+
|
|
503
|
+
export const productEntity = createEntity({
|
|
504
|
+
table: "read_sample_products",
|
|
505
|
+
fields: {
|
|
506
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
export const productTable = buildEntityTable("product", productEntity);
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### src/feature.ts
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
// Embedded Object Sample
|
|
517
|
+
// Shows: Embedded objects stored as JSONB, searchable sub-fields,
|
|
518
|
+
// field access on sub-fields, required vs optional embedded objects
|
|
519
|
+
//
|
|
520
|
+
// Pattern: Address belongs 1:1 to contact — never shared, always read together.
|
|
521
|
+
// Use embedded when: data is owned by the parent entity, not referenced elsewhere.
|
|
522
|
+
//
|
|
523
|
+
// Tables:
|
|
524
|
+
// contact — with embedded address (required) and billingAddress (optional)
|
|
525
|
+
// address.street and address.city are searchable
|
|
526
|
+
// billingAddress.vatId has restricted field access
|
|
527
|
+
// product — minimal entity, only exists so `invoice.lines` has something
|
|
528
|
+
// to reference
|
|
529
|
+
// invoice — embedded list with select/reference/derived/totals metadata
|
|
530
|
+
// on its `lines` sub-fields (Issue #1835), plus a declarative
|
|
531
|
+
// entityEdit screen that renders the list end-to-end via
|
|
532
|
+
// EmbeddedListField, no custom-screen code required
|
|
533
|
+
|
|
534
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
535
|
+
import { contactEntity } from "./entities/contact";
|
|
536
|
+
import { invoiceEntity } from "./entities/invoice";
|
|
537
|
+
import { productEntity } from "./entities/product";
|
|
538
|
+
import { contactCreate } from "./handlers/contact-create.write";
|
|
539
|
+
import { contactDetail } from "./handlers/contact-detail.query";
|
|
540
|
+
|
|
541
|
+
export { contactEntity } from "./entities/contact";
|
|
542
|
+
export { invoiceEntity } from "./entities/invoice";
|
|
543
|
+
export { productEntity } from "./entities/product";
|
|
544
|
+
|
|
545
|
+
const adminWrite = { access: { roles: ["Admin"] } } as const;
|
|
546
|
+
const openRead = {
|
|
547
|
+
access: {
|
|
548
|
+
openToAll: {
|
|
549
|
+
reason:
|
|
550
|
+
"any signed-in user may read products and invoices in this recipe; writes are " +
|
|
551
|
+
"still gated by adminWrite",
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
} as const;
|
|
555
|
+
|
|
556
|
+
export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
557
|
+
r.entity("contact", contactEntity);
|
|
558
|
+
|
|
559
|
+
r.writeHandler(contactCreate);
|
|
560
|
+
r.queryHandler(contactDetail);
|
|
561
|
+
|
|
562
|
+
r.crud("product", productEntity, { write: adminWrite, read: openRead });
|
|
563
|
+
r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
|
|
564
|
+
|
|
565
|
+
r.screen({
|
|
566
|
+
id: "invoice-edit",
|
|
567
|
+
type: "entityEdit",
|
|
568
|
+
entity: "invoice",
|
|
569
|
+
layout: {
|
|
570
|
+
sections: [{ columns: 1, fields: ["customer", "lines"] }],
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### src/handlers/contact-create.write.ts
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
580
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
581
|
+
import { z } from "zod";
|
|
582
|
+
import { contactEntity, contactTable } from "../entities/contact";
|
|
583
|
+
|
|
584
|
+
const contactCrud = createEventStoreExecutor(contactTable, contactEntity, {
|
|
585
|
+
entityName: "contact",
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const addressSchema = z.object({
|
|
589
|
+
street: z.string().min(1),
|
|
590
|
+
zip: z.string().min(1),
|
|
591
|
+
city: z.string().min(1),
|
|
592
|
+
country: z.string().optional(),
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const billingAddressSchema = z.object({
|
|
596
|
+
street: z.string().min(1),
|
|
597
|
+
zip: z.string().min(1),
|
|
598
|
+
city: z.string().min(1),
|
|
599
|
+
country: z.string().optional(),
|
|
600
|
+
vatId: z.string().optional(),
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const phoneNumberSchema = z.object({
|
|
604
|
+
label: z.string().min(1),
|
|
605
|
+
number: z.string().min(1),
|
|
606
|
+
note: z.string().optional(),
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
export const contactCreate = defineWriteHandler({
|
|
610
|
+
name: "contact:create",
|
|
611
|
+
schema: z.object({
|
|
612
|
+
name: z.string().min(1),
|
|
613
|
+
email: z.email().optional(),
|
|
614
|
+
address: addressSchema,
|
|
615
|
+
billingAddress: billingAddressSchema.optional(),
|
|
616
|
+
phoneNumbers: z.array(phoneNumberSchema).optional(),
|
|
617
|
+
}),
|
|
618
|
+
access: { roles: ["Admin"] },
|
|
619
|
+
// Embedded fields (address/billingAddress) arrive as objects, the embedded
|
|
620
|
+
// list (phoneNumbers) as an array — already the combined API form the
|
|
621
|
+
// executor expects; it flattens them into the read row.
|
|
622
|
+
handler: async (event, ctx) => contactCrud.create(event.payload, event.user, ctx.db),
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### src/handlers/contact-detail.query.ts
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
import { defineQueryHandler, filterReadFields } from "@cosmicdrift/kumiko-framework/engine";
|
|
630
|
+
import { z } from "zod";
|
|
631
|
+
import { contactEntity, contactTable } from "../entities/contact";
|
|
632
|
+
|
|
633
|
+
export const contactDetail = defineQueryHandler({
|
|
634
|
+
name: "contact:detail",
|
|
635
|
+
schema: z.object({ id: z.uuid() }),
|
|
636
|
+
access: {
|
|
637
|
+
openToAll: {
|
|
638
|
+
reason:
|
|
639
|
+
"demo recipe: any signed-in user may look up any contact by id, subject to " +
|
|
640
|
+
"the entity's own field-level read access on restricted sub-fields like billingAddress.vatId",
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
handler: async (query, ctx) => {
|
|
644
|
+
const [row] = await ctx.db.selectMany(contactTable, { id: query.payload.id });
|
|
645
|
+
if (!row) return null;
|
|
646
|
+
return filterReadFields(contactEntity, row as Record<string, unknown>, query.user);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
```
|