@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,187 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/realtime-sse. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# realtime-sse
|
|
4
|
+
|
|
5
|
+
Server-sent events demo. Goodbye F5.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Realtime SSE
|
|
10
|
+
|
|
11
|
+
Server-sent events — live updates without F5.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Entity writes that fan out over SSE
|
|
16
|
+
- Client subscription shape for live lists
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/realtime-sse
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Realtime SSE Sample — Integration Test
|
|
37
|
+
// Proves: SSE events fire on create, update, delete through the async
|
|
38
|
+
// event-dispatcher (D.3). Shape mirrors the StoredEvent: `type` is the
|
|
39
|
+
// event type ("message.created"), `data` carries id/version only: the
|
|
40
|
+
// frame is a change signal, clients refetch the record through a query.
|
|
41
|
+
|
|
42
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
43
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
45
|
+
import {
|
|
46
|
+
createTestUser,
|
|
47
|
+
setupTestStack,
|
|
48
|
+
type TestStack,
|
|
49
|
+
TestUsers,
|
|
50
|
+
unsafeCreateEntityTable,
|
|
51
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
52
|
+
import { chatFeature, messageEntity } from "../feature";
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
|
|
56
|
+
const admin = TestUsers.admin;
|
|
57
|
+
const user = createTestUser({ id: 2, roles: ["User"] });
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
stack = await setupTestStack({ features: [chatFeature] });
|
|
61
|
+
await unsafeCreateEntityTable(stack.db, messageEntity);
|
|
62
|
+
await createEventsTable(stack.db);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await stack.cleanup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
// Advance the event-dispatcher past previous-test events, then reset the
|
|
71
|
+
// collector. Without this, each test would see SSE events stacked up from
|
|
72
|
+
// its predecessors.
|
|
73
|
+
await stack.eventDispatcher?.runOnce();
|
|
74
|
+
stack.events.reset();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("SSE broadcast on create", () => {
|
|
78
|
+
test("emits message.created event", async () => {
|
|
79
|
+
await stack.http.writeOk<SaveContext>(
|
|
80
|
+
"chat:write:message:create",
|
|
81
|
+
{
|
|
82
|
+
channel: "general",
|
|
83
|
+
text: "Hello world",
|
|
84
|
+
author: "Alice",
|
|
85
|
+
},
|
|
86
|
+
user,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// SSE is delivered asynchronously by the event-dispatcher. Drain once so
|
|
90
|
+
// the test observes a deterministic state.
|
|
91
|
+
await stack.eventDispatcher?.runOnce();
|
|
92
|
+
|
|
93
|
+
expect(stack.events.sse).toHaveLength(1);
|
|
94
|
+
expect(stack.events.sse[0]?.type).toBe("message.created");
|
|
95
|
+
expect(stack.events.sse[0]?.data["id"]).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("SSE broadcast on update", () => {
|
|
100
|
+
test("emits a message.updated signal without field values", async () => {
|
|
101
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
102
|
+
"chat:write:message:create",
|
|
103
|
+
{
|
|
104
|
+
channel: "general",
|
|
105
|
+
text: "Original",
|
|
106
|
+
},
|
|
107
|
+
user,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Drain the create event, then reset so the update assertion is isolated.
|
|
111
|
+
await stack.eventDispatcher?.runOnce();
|
|
112
|
+
stack.events.reset();
|
|
113
|
+
|
|
114
|
+
await stack.http.writeOk<SaveContext>(
|
|
115
|
+
"chat:write:message:update",
|
|
116
|
+
{
|
|
117
|
+
id: created.id,
|
|
118
|
+
changes: { text: "Edited" },
|
|
119
|
+
version: 1,
|
|
120
|
+
},
|
|
121
|
+
user,
|
|
122
|
+
);
|
|
123
|
+
await stack.eventDispatcher?.runOnce();
|
|
124
|
+
|
|
125
|
+
const updateEvent = stack.events.sse.find((e) => e.type === "message.updated");
|
|
126
|
+
expect(updateEvent).toBeDefined();
|
|
127
|
+
expect(updateEvent?.data["id"]).toBe(created.id);
|
|
128
|
+
expect(updateEvent?.data["version"]).toBe(2);
|
|
129
|
+
expect(updateEvent?.data).not.toHaveProperty("payload");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("SSE broadcast on delete", () => {
|
|
134
|
+
test("emits message.deleted event", async () => {
|
|
135
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
136
|
+
"chat:write:message:create",
|
|
137
|
+
{
|
|
138
|
+
channel: "general",
|
|
139
|
+
text: "Delete me",
|
|
140
|
+
},
|
|
141
|
+
user,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await stack.eventDispatcher?.runOnce();
|
|
145
|
+
stack.events.reset();
|
|
146
|
+
|
|
147
|
+
await stack.http.writeOk<SaveContext>("chat:write:message:delete", { id: created.id }, admin);
|
|
148
|
+
await stack.eventDispatcher?.runOnce();
|
|
149
|
+
|
|
150
|
+
const deleteEvent = stack.events.sse.find((e) => e.type === "message.deleted");
|
|
151
|
+
expect(deleteEvent).toBeDefined();
|
|
152
|
+
expect(deleteEvent?.data["id"]).toBe(created.id);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### src/feature.ts
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import {
|
|
161
|
+
createEntity,
|
|
162
|
+
createTextField,
|
|
163
|
+
defineEntityDeleteHandler,
|
|
164
|
+
defineFeature,
|
|
165
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
166
|
+
|
|
167
|
+
export const messageEntity = createEntity({
|
|
168
|
+
table: "read_sample_messages",
|
|
169
|
+
fields: {
|
|
170
|
+
channel: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
171
|
+
text: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
172
|
+
author: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
173
|
+
},
|
|
174
|
+
softDelete: true,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const userWrite = { access: { roles: ["Admin", "User"] } } as const;
|
|
178
|
+
const adminWrite = { access: { roles: ["Admin"] } } as const;
|
|
179
|
+
|
|
180
|
+
export const chatFeature = defineFeature("chat", (r) => {
|
|
181
|
+
r.crud("message", messageEntity, {
|
|
182
|
+
write: userWrite,
|
|
183
|
+
verbs: { list: false, detail: false, restore: false, delete: false },
|
|
184
|
+
});
|
|
185
|
+
r.writeHandler(defineEntityDeleteHandler("message", messageEntity, adminWrite));
|
|
186
|
+
});
|
|
187
|
+
```
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/record-detail-layout. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# record-detail-layout
|
|
4
|
+
|
|
5
|
+
projectionDetail record header, metrics band, and layout.mode: "tabs" — an order screen with two relatedList tabs and a fields tab.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Record Detail Layout
|
|
10
|
+
|
|
11
|
+
Register a `projectionDetail` screen as a tabbed record view ("Akte"): a
|
|
12
|
+
header, a metrics band, and a tabbed layout instead of one long stacked form.
|
|
13
|
+
|
|
14
|
+
## What it shows
|
|
15
|
+
|
|
16
|
+
- `header` (title/subtitle/status) and `metrics` (labeled via `fieldLabels`)
|
|
17
|
+
rendered from the columns of the screen's own query row
|
|
18
|
+
- `layout.mode: "tabs"` with two `relatedList` tabs (order items, payments)
|
|
19
|
+
and one `fields` tab (master data) — each `relatedList` runs its own query
|
|
20
|
+
- boot-validator catching two common author mistakes: a `metrics` entry
|
|
21
|
+
without a `fieldLabels` override, and a `tabs` layout with fewer than two
|
|
22
|
+
sections
|
|
23
|
+
|
|
24
|
+
## Source
|
|
25
|
+
|
|
26
|
+
Feature entry point: `src/feature.ts`.
|
|
27
|
+
|
|
28
|
+
## Tests
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd samples/recipes/record-detail-layout
|
|
32
|
+
bun test
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Source files
|
|
36
|
+
|
|
37
|
+
### src/__tests__/feature.test.ts
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// Record Detail Layout Showcase — Unit Test (no DB / HTTP needed).
|
|
41
|
+
// Proves:
|
|
42
|
+
// - header/metrics/tabs land on the registered ProjectionDetailScreenDefinition
|
|
43
|
+
// exactly as declared (column names, not literals)
|
|
44
|
+
// - relatedList columns collapse the string shorthand like entityList columns do
|
|
45
|
+
// - the boot-validator catches the two mistakes the projectionDetail tabs
|
|
46
|
+
// layout introduces: a metric without a fieldLabels entry, and a tabs
|
|
47
|
+
// layout with fewer than two sections
|
|
48
|
+
|
|
49
|
+
import { describe, expect, test } from "bun:test";
|
|
50
|
+
import {
|
|
51
|
+
createRegistry,
|
|
52
|
+
defineFeature,
|
|
53
|
+
normalizeListColumn,
|
|
54
|
+
validateBoot as validateBootRaw,
|
|
55
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
56
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
|
|
57
|
+
import { z } from "zod";
|
|
58
|
+
import { createOrderDeskFeature } from "../feature";
|
|
59
|
+
|
|
60
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
61
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const orderDesk = createOrderDeskFeature();
|
|
65
|
+
const registry = createRegistry([orderDesk]);
|
|
66
|
+
|
|
67
|
+
describe("record-detail-layout showcase — registration", () => {
|
|
68
|
+
test("validateBoot accepts the full registered app", () => {
|
|
69
|
+
expect(() => validateBoot([orderDesk])).not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("order-detail registers as projectionDetail with a qualified id", () => {
|
|
73
|
+
const screen = registry.getScreen("order-desk:screen:order-detail");
|
|
74
|
+
expect(screen?.type).toBe("projectionDetail");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("header and metrics store column names, not literals", () => {
|
|
78
|
+
const screen = registry.getScreen("order-desk:screen:order-detail");
|
|
79
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
80
|
+
expect(screen.header).toEqual({
|
|
81
|
+
title: "customerName",
|
|
82
|
+
subtitle: "orderNumber",
|
|
83
|
+
status: "status",
|
|
84
|
+
});
|
|
85
|
+
expect(screen.metrics).toEqual(["totalAmount", "outstandingAmount", "itemCount", "placedAt"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("layout.mode is tabs with two relatedList sections, one fields section, and one extension section", () => {
|
|
89
|
+
const screen = registry.getScreen("order-desk:screen:order-detail");
|
|
90
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
91
|
+
expect(screen.layout.mode).toBe("tabs");
|
|
92
|
+
expect(screen.layout.sections.map((s) => s.kind)).toEqual([
|
|
93
|
+
"relatedList",
|
|
94
|
+
"relatedList",
|
|
95
|
+
"fields",
|
|
96
|
+
"extension",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("screen-level actions carry a refresh-order navigate action", () => {
|
|
101
|
+
const screen = registry.getScreen("order-desk:screen:order-detail");
|
|
102
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
103
|
+
expect(screen.actions?.map((a) => a.id)).toEqual(["refresh-order"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("relatedList columns collapse string shorthand into the object form", () => {
|
|
107
|
+
const screen = registry.getScreen("order-desk:screen:order-detail");
|
|
108
|
+
if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
|
|
109
|
+
const itemsSection = screen.layout.sections[0];
|
|
110
|
+
if (itemsSection?.kind !== "relatedList") throw new Error("expected relatedList");
|
|
111
|
+
expect(normalizeListColumn(itemsSection.columns[0]!)).toEqual({ field: "sku" });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("record-detail-layout showcase — boot-validator catches author mistakes", () => {
|
|
116
|
+
test("metric without a fieldLabels entry fails boot — no fallback to the raw column name", () => {
|
|
117
|
+
const broken = defineFeature("broken-order-desk", (r) => {
|
|
118
|
+
r.queryHandler("order:detail", z.object({ id: z.string() }), async () => ({ id: "1" }), {
|
|
119
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
120
|
+
});
|
|
121
|
+
r.screen({
|
|
122
|
+
id: "order-detail",
|
|
123
|
+
type: "projectionDetail",
|
|
124
|
+
query: "broken-order-desk:query:order:detail",
|
|
125
|
+
metrics: ["totalAmount"],
|
|
126
|
+
layout: { sections: [{ title: "Details", fields: ["id"] }] },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
expect(() => validateBoot([broken])).toThrow(
|
|
130
|
+
/metric "totalAmount" has no entry in fieldLabels/,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("tabs with only one section fails boot", () => {
|
|
135
|
+
const broken = defineFeature("broken-order-desk-tabs", (r) => {
|
|
136
|
+
r.queryHandler("order:detail", z.object({ id: z.string() }), async () => ({ id: "1" }), {
|
|
137
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
138
|
+
});
|
|
139
|
+
r.screen({
|
|
140
|
+
id: "order-detail",
|
|
141
|
+
type: "projectionDetail",
|
|
142
|
+
query: "broken-order-desk-tabs:query:order:detail",
|
|
143
|
+
layout: {
|
|
144
|
+
mode: "tabs",
|
|
145
|
+
sections: [{ id: "only", title: "Only", fields: ["id"] }],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
expect(() => validateBoot([broken])).toThrow(/tabs need at least 2 sections/);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### src/feature.ts
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// Record Detail Layout Showcase — a projectionDetail screen with the
|
|
158
|
+
// "Akte" (case file) layout:
|
|
159
|
+
// 1. `header` renders the query row's customer/order/status columns above
|
|
160
|
+
// the layout — the identity a support agent scans first.
|
|
161
|
+
// 2. `metrics` renders a labeled band of key numbers from the same row —
|
|
162
|
+
// labels come from `fieldLabels`, there is no fallback to the raw
|
|
163
|
+
// column name.
|
|
164
|
+
// 3. `layout.mode: "tabs"` splits the record into four tabs: two
|
|
165
|
+
// `relatedList` sections (own query, own pagination, mounts only when
|
|
166
|
+
// active), one `fields` section (master data from the detail row), and
|
|
167
|
+
// one `extension` section (a plugin-mounted component, fw#3234 padding-
|
|
168
|
+
// parity check — every tab kind must sit in the same Card padding, no
|
|
169
|
+
// section carries extra chrome of its own).
|
|
170
|
+
// 4. A screen-level `actions` entry (fw#3234) renders next to the header,
|
|
171
|
+
// not inside any tab's Card.
|
|
172
|
+
//
|
|
173
|
+
// projectionDetail has no entity — it binds to an explicit query instead, so
|
|
174
|
+
// every field name below (header, metrics, fields) is a column of that
|
|
175
|
+
// query's row, not an entity field.
|
|
176
|
+
|
|
177
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
178
|
+
import { z } from "zod";
|
|
179
|
+
|
|
180
|
+
const rowIdSchema = z.object({ id: z.string() });
|
|
181
|
+
|
|
182
|
+
export function createOrderDeskFeature(): FeatureDefinition {
|
|
183
|
+
return defineFeature("order-desk", (r) => {
|
|
184
|
+
r.systemScope();
|
|
185
|
+
|
|
186
|
+
r.queryHandler(
|
|
187
|
+
"order:detail",
|
|
188
|
+
rowIdSchema,
|
|
189
|
+
async (query) => ({
|
|
190
|
+
id: query.payload.id,
|
|
191
|
+
customerName: "Aiko Tanaka",
|
|
192
|
+
orderNumber: "SO-10482",
|
|
193
|
+
status: "processing",
|
|
194
|
+
totalAmount: "€ 1,248.00",
|
|
195
|
+
outstandingAmount: "€ 312.00",
|
|
196
|
+
itemCount: "3",
|
|
197
|
+
placedAt: "2026-08-21",
|
|
198
|
+
shippingAddress: "Karl-Marx-Straße 12, 12043 Berlin",
|
|
199
|
+
billingAddress: "Karl-Marx-Straße 12, 12043 Berlin",
|
|
200
|
+
salesRep: "Jonas Weber",
|
|
201
|
+
}),
|
|
202
|
+
{ access: { roles: ["Support", "Admin"] } },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
r.queryHandler(
|
|
206
|
+
"order:items",
|
|
207
|
+
rowIdSchema,
|
|
208
|
+
async () => ({
|
|
209
|
+
rows: [
|
|
210
|
+
{ sku: "KB-100", description: "Mechanical keyboard", quantity: 1, unitPrice: 148 },
|
|
211
|
+
{ sku: "MS-220", description: "Wireless mouse", quantity: 2, unitPrice: 45 },
|
|
212
|
+
{ sku: "PAD-01", description: "Desk mat", quantity: 1, unitPrice: 22 },
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
{ access: { roles: ["Support", "Admin"] } },
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
r.queryHandler(
|
|
219
|
+
"order:payments",
|
|
220
|
+
rowIdSchema,
|
|
221
|
+
async () => ({
|
|
222
|
+
rows: [
|
|
223
|
+
{ paidAt: "2026-08-21", amount: 936, method: "card", status: "settled" },
|
|
224
|
+
{ paidAt: "2026-08-25", amount: 312, method: "invoice", status: "pending" },
|
|
225
|
+
],
|
|
226
|
+
}),
|
|
227
|
+
{ access: { roles: ["Support", "Admin"] } },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
r.screen({
|
|
231
|
+
id: "order-detail",
|
|
232
|
+
type: "projectionDetail",
|
|
233
|
+
query: "order-desk:query:order:detail",
|
|
234
|
+
header: { title: "customerName", subtitle: "orderNumber", status: "status" },
|
|
235
|
+
metrics: ["totalAmount", "outstandingAmount", "itemCount", "placedAt"],
|
|
236
|
+
fieldLabels: {
|
|
237
|
+
totalAmount: "order-desk.metric.totalAmount",
|
|
238
|
+
outstandingAmount: "order-desk.metric.outstandingAmount",
|
|
239
|
+
itemCount: "order-desk.metric.itemCount",
|
|
240
|
+
placedAt: "order-desk.metric.placedAt",
|
|
241
|
+
},
|
|
242
|
+
layout: {
|
|
243
|
+
mode: "tabs",
|
|
244
|
+
sections: [
|
|
245
|
+
{
|
|
246
|
+
id: "items",
|
|
247
|
+
kind: "relatedList",
|
|
248
|
+
title: "order-desk.tab.items",
|
|
249
|
+
query: "order-desk:query:order:items",
|
|
250
|
+
columns: [
|
|
251
|
+
"sku",
|
|
252
|
+
"description",
|
|
253
|
+
"quantity",
|
|
254
|
+
{ field: "unitPrice", renderer: { format: "currency", symbol: "€" } },
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "payments",
|
|
259
|
+
kind: "relatedList",
|
|
260
|
+
title: "order-desk.tab.payments",
|
|
261
|
+
query: "order-desk:query:order:payments",
|
|
262
|
+
columns: [
|
|
263
|
+
"paidAt",
|
|
264
|
+
{ field: "amount", renderer: { format: "currency", symbol: "€" } },
|
|
265
|
+
"method",
|
|
266
|
+
"status",
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "details",
|
|
271
|
+
kind: "fields",
|
|
272
|
+
title: "order-desk.tab.details",
|
|
273
|
+
fields: ["shippingAddress", "billingAddress", "salesRep"],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: "internal-note",
|
|
277
|
+
kind: "extension",
|
|
278
|
+
title: "order-desk.tab.internalNote",
|
|
279
|
+
component: { react: { __component: "OrderInternalNote" } },
|
|
280
|
+
entityName: "order",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
actions: [
|
|
285
|
+
{
|
|
286
|
+
kind: "navigate",
|
|
287
|
+
id: "refresh-order",
|
|
288
|
+
label: "order-desk.action.refreshOrder",
|
|
289
|
+
screen: "order-detail",
|
|
290
|
+
icon: "refresh",
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
access: { roles: ["Support", "Admin"] },
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
```
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/reference-data. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# reference-data
|
|
4
|
+
|
|
5
|
+
Upsert-based seeds. Lookup tables done right.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Reference Data
|
|
10
|
+
|
|
11
|
+
Seed reference data with `r.referenceData`.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Declarative reference rows loaded at boot
|
|
16
|
+
- Idempotent seeding for lookup tables
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/reference-data
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Reference Data Sample — Integration Test
|
|
37
|
+
// Proves: seed data is inserted, upsert updates existing rows, new rows added
|
|
38
|
+
|
|
39
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
40
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
41
|
+
import { seedReferenceData } from "@cosmicdrift/kumiko-framework/db";
|
|
42
|
+
import type { ReferenceDataDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
43
|
+
import { createTestDb, type TestDb, unsafePushTables } from "@cosmicdrift/kumiko-framework/stack";
|
|
44
|
+
import { categoryFeature, categoryTable } from "../feature";
|
|
45
|
+
|
|
46
|
+
let testDb: TestDb;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
testDb = await createTestDb();
|
|
50
|
+
await unsafePushTables(testDb.db, { categoryTable });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await testDb?.cleanup();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
type CategoryRow = { code: string; name: string; description: string | null };
|
|
58
|
+
|
|
59
|
+
async function readCategories(): Promise<CategoryRow[]> {
|
|
60
|
+
const rows = await selectMany(testDb.db, categoryTable);
|
|
61
|
+
return [...rows].sort((a: CategoryRow, b: CategoryRow) => a.code.localeCompare(b.code));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("reference data seeding", () => {
|
|
65
|
+
test("inserts initial seed data", async () => {
|
|
66
|
+
const tables = new Map([["category", categoryTable]]);
|
|
67
|
+
|
|
68
|
+
// seedReferenceData(defs, tables, db)
|
|
69
|
+
await seedReferenceData(categoryFeature.referenceData, tables, testDb.db);
|
|
70
|
+
|
|
71
|
+
const rows = await readCategories();
|
|
72
|
+
expect(rows).toHaveLength(4);
|
|
73
|
+
expect(rows[0]?.code).toBe("books");
|
|
74
|
+
expect(rows[1]?.code).toBe("clothing");
|
|
75
|
+
expect(rows[2]?.code).toBe("electronics");
|
|
76
|
+
expect(rows[3]?.code).toBe("food");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("upsert updates existing rows without duplicating", async () => {
|
|
80
|
+
const tables = new Map([["category", categoryTable]]);
|
|
81
|
+
|
|
82
|
+
// Modified seed data: changed name for "books", added new category
|
|
83
|
+
const updatedDefs: ReferenceDataDef[] = [
|
|
84
|
+
{
|
|
85
|
+
entityName: "category",
|
|
86
|
+
data: [
|
|
87
|
+
{ code: "electronics", name: "Electronics", description: "Phones, laptops, tablets" },
|
|
88
|
+
{ code: "clothing", name: "Clothing", description: "Shirts, pants, shoes" },
|
|
89
|
+
{ code: "books", name: "Books & Media", description: "Books, audiobooks, magazines" },
|
|
90
|
+
{ code: "food", name: "Food & Drinks", description: "Groceries and beverages" },
|
|
91
|
+
{ code: "sports", name: "Sports", description: "Equipment and gear" },
|
|
92
|
+
],
|
|
93
|
+
upsertKey: "code",
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
await seedReferenceData(updatedDefs, tables, testDb.db);
|
|
98
|
+
|
|
99
|
+
const rows = await readCategories();
|
|
100
|
+
expect(rows).toHaveLength(5);
|
|
101
|
+
|
|
102
|
+
const books = rows.find((r: CategoryRow) => r.code === "books");
|
|
103
|
+
expect(books?.name).toBe("Books & Media");
|
|
104
|
+
|
|
105
|
+
const sports = rows.find((r: CategoryRow) => r.code === "sports");
|
|
106
|
+
expect(sports?.name).toBe("Sports");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### src/feature.ts
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// Reference Data Sample
|
|
115
|
+
// Shows: r.referenceData() for static seed data with upsert logic
|
|
116
|
+
|
|
117
|
+
import { table, text } from "@cosmicdrift/kumiko-framework/db";
|
|
118
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
119
|
+
|
|
120
|
+
export const categoryEntity = createEntity({
|
|
121
|
+
table: "read_sample_categories",
|
|
122
|
+
fields: {
|
|
123
|
+
code: createTextField({ personal: false, reason: "is_reference_data", required: true }),
|
|
124
|
+
name: createTextField({ personal: false, reason: "is_reference_data", required: true }),
|
|
125
|
+
description: createTextField({ personal: false, reason: "is_reference_data" }),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Drizzle table for direct DB queries in tests
|
|
130
|
+
export const categoryTable = table("read_sample_categories", {
|
|
131
|
+
code: text("code").notNull(),
|
|
132
|
+
name: text("name").notNull(),
|
|
133
|
+
description: text("description"),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const categoryFeature = defineFeature("catalog", (r) => {
|
|
137
|
+
r.entity("category", categoryEntity);
|
|
138
|
+
|
|
139
|
+
// Static reference data — seeded on boot, upserted on changes
|
|
140
|
+
r.referenceData(
|
|
141
|
+
"category",
|
|
142
|
+
[
|
|
143
|
+
{ code: "electronics", name: "Electronics", description: "Phones, laptops, tablets" },
|
|
144
|
+
{ code: "clothing", name: "Clothing", description: "Shirts, pants, shoes" },
|
|
145
|
+
{ code: "books", name: "Books", description: "Fiction and non-fiction" },
|
|
146
|
+
{ code: "food", name: "Food & Drinks", description: "Groceries and beverages" },
|
|
147
|
+
],
|
|
148
|
+
{ upsertKey: "code" },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
```
|