@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,280 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/relations. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# relations
|
|
4
|
+
|
|
5
|
+
belongsTo, hasMany, manyToMany — the good stuff.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Relations
|
|
10
|
+
|
|
11
|
+
Parent-child relations with cascade / restrict.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Relation declarations between entities
|
|
16
|
+
- Delete behaviour (cascade vs restrict)
|
|
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/relations
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Relations Sample — Integration Test
|
|
37
|
+
// Proves: hasMany relations, cascade delete, restrict delete
|
|
38
|
+
|
|
39
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
40
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
41
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
42
|
+
import { createCascadeDeleteHook } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
43
|
+
import {
|
|
44
|
+
setupTestStack,
|
|
45
|
+
type TestStack,
|
|
46
|
+
TestUsers,
|
|
47
|
+
unsafeCreateEntityTable,
|
|
48
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
49
|
+
import {
|
|
50
|
+
memberEntity,
|
|
51
|
+
memberTable,
|
|
52
|
+
relationsFeature,
|
|
53
|
+
taskEntity,
|
|
54
|
+
taskTable,
|
|
55
|
+
teamEntity,
|
|
56
|
+
} from "../feature";
|
|
57
|
+
|
|
58
|
+
let stack: TestStack;
|
|
59
|
+
|
|
60
|
+
const admin = TestUsers.admin;
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
stack = await setupTestStack({ features: [relationsFeature] });
|
|
64
|
+
await unsafeCreateEntityTable(stack.db, teamEntity);
|
|
65
|
+
await unsafeCreateEntityTable(stack.db, memberEntity);
|
|
66
|
+
await unsafeCreateEntityTable(stack.db, taskEntity);
|
|
67
|
+
await createEventsTable(stack.db);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
await stack.cleanup();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
stack.events.reset();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("restrict: team cannot be deleted with members", () => {
|
|
79
|
+
test("delete team with members fails", async () => {
|
|
80
|
+
const team = await stack.http.writeOk<SaveContext>(
|
|
81
|
+
"org:write:team:create",
|
|
82
|
+
{ name: "Engineering" },
|
|
83
|
+
admin,
|
|
84
|
+
);
|
|
85
|
+
await stack.http.writeOk<SaveContext>(
|
|
86
|
+
"org:write:member:create",
|
|
87
|
+
{
|
|
88
|
+
name: "Alice",
|
|
89
|
+
teamId: team.id,
|
|
90
|
+
},
|
|
91
|
+
admin,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Cascade hook would block this — test via hook directly
|
|
95
|
+
const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["member", memberTable]]));
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
cascadeHook.fn(
|
|
99
|
+
{
|
|
100
|
+
kind: "delete",
|
|
101
|
+
id: team.id,
|
|
102
|
+
data: { tenantId: "00000000-0000-4000-8000-000000000001" },
|
|
103
|
+
entityName: "team",
|
|
104
|
+
},
|
|
105
|
+
{ db: stack.db },
|
|
106
|
+
),
|
|
107
|
+
).rejects.toMatchObject({ code: "conflict", details: { reason: "delete_restricted" } });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("delete empty team succeeds", async () => {
|
|
111
|
+
const team = await stack.http.writeOk<SaveContext>(
|
|
112
|
+
"org:write:team:create",
|
|
113
|
+
{ name: "Empty Team" },
|
|
114
|
+
admin,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["member", memberTable]]));
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
cascadeHook.fn(
|
|
121
|
+
{
|
|
122
|
+
kind: "delete",
|
|
123
|
+
id: team.id,
|
|
124
|
+
data: { tenantId: "00000000-0000-4000-8000-000000000001" },
|
|
125
|
+
entityName: "team",
|
|
126
|
+
},
|
|
127
|
+
{ db: stack.db },
|
|
128
|
+
),
|
|
129
|
+
).resolves.toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("cascade: deleting member removes tasks", () => {
|
|
134
|
+
test("member's tasks are deleted when member is deleted", async () => {
|
|
135
|
+
const team = await stack.http.writeOk<SaveContext>(
|
|
136
|
+
"org:write:team:create",
|
|
137
|
+
{ name: "Cascade Team" },
|
|
138
|
+
admin,
|
|
139
|
+
);
|
|
140
|
+
const member = await stack.http.writeOk<SaveContext>(
|
|
141
|
+
"org:write:member:create",
|
|
142
|
+
{
|
|
143
|
+
name: "Bob",
|
|
144
|
+
teamId: team.id,
|
|
145
|
+
},
|
|
146
|
+
admin,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const task1 = await stack.http.writeOk<SaveContext>(
|
|
150
|
+
"org:write:task:create",
|
|
151
|
+
{
|
|
152
|
+
title: "Task A",
|
|
153
|
+
memberId: member.id,
|
|
154
|
+
},
|
|
155
|
+
admin,
|
|
156
|
+
);
|
|
157
|
+
const task2 = await stack.http.writeOk<SaveContext>(
|
|
158
|
+
"org:write:task:create",
|
|
159
|
+
{
|
|
160
|
+
title: "Task B",
|
|
161
|
+
memberId: member.id,
|
|
162
|
+
},
|
|
163
|
+
admin,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Run cascade hook
|
|
167
|
+
const cascadeHook = createCascadeDeleteHook(stack.registry, new Map([["task", taskTable]]));
|
|
168
|
+
|
|
169
|
+
await cascadeHook.fn(
|
|
170
|
+
{
|
|
171
|
+
kind: "delete",
|
|
172
|
+
id: member.id,
|
|
173
|
+
data: { tenantId: "00000000-0000-4000-8000-000000000001" },
|
|
174
|
+
entityName: "member",
|
|
175
|
+
},
|
|
176
|
+
{ db: stack.db },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Tasks should be gone
|
|
180
|
+
const detail1 = await stack.http.queryOk<null>(
|
|
181
|
+
"org:query:task:detail",
|
|
182
|
+
{ id: task1.id },
|
|
183
|
+
admin,
|
|
184
|
+
);
|
|
185
|
+
const detail2 = await stack.http.queryOk<null>(
|
|
186
|
+
"org:query:task:detail",
|
|
187
|
+
{ id: task2.id },
|
|
188
|
+
admin,
|
|
189
|
+
);
|
|
190
|
+
expect(detail1).toBeNull();
|
|
191
|
+
expect(detail2).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### src/feature.ts
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
200
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
201
|
+
|
|
202
|
+
export const teamEntity = createEntity({
|
|
203
|
+
table: "read_sample_teams",
|
|
204
|
+
fields: {
|
|
205
|
+
name: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
export const memberEntity = createEntity({
|
|
210
|
+
table: "read_sample_members",
|
|
211
|
+
fields: {
|
|
212
|
+
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
213
|
+
teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
|
|
214
|
+
role: createTextField({ personal: false, reason: "is_business_data" }),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export const taskEntity = createEntity({
|
|
219
|
+
table: "read_sample_member_tasks",
|
|
220
|
+
fields: {
|
|
221
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
222
|
+
memberId: createTextField({ personal: "ref", required: true }),
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export const teamTable = buildEntityTable("team", teamEntity);
|
|
227
|
+
export const memberTable = buildEntityTable("member", memberEntity);
|
|
228
|
+
export const taskTable = buildEntityTable("task", taskEntity);
|
|
229
|
+
|
|
230
|
+
const adminWrite = { access: { roles: ["Admin"] } } as const;
|
|
231
|
+
const openRead = {
|
|
232
|
+
access: {
|
|
233
|
+
openToAll: {
|
|
234
|
+
reason: "any signed-in user may read a task by id; writes are still gated by adminWrite",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
} as const;
|
|
238
|
+
const createOnly = {
|
|
239
|
+
update: false,
|
|
240
|
+
delete: false,
|
|
241
|
+
restore: false,
|
|
242
|
+
list: false,
|
|
243
|
+
detail: false,
|
|
244
|
+
} as const;
|
|
245
|
+
|
|
246
|
+
export const relationsFeature = defineFeature("org", (r) => {
|
|
247
|
+
const team = r.entity("team", teamEntity);
|
|
248
|
+
const member = r.entity("member", memberEntity);
|
|
249
|
+
|
|
250
|
+
r.relation(team, "members", {
|
|
251
|
+
type: "hasMany",
|
|
252
|
+
target: "member",
|
|
253
|
+
foreignKey: "teamId",
|
|
254
|
+
onDelete: "restrict",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
r.relation(member, "tasks", {
|
|
258
|
+
type: "hasMany",
|
|
259
|
+
target: "task",
|
|
260
|
+
foreignKey: "memberId",
|
|
261
|
+
onDelete: "cascade",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
r.crud("team", teamEntity, {
|
|
265
|
+
write: adminWrite,
|
|
266
|
+
verbs: createOnly,
|
|
267
|
+
registerEntity: false,
|
|
268
|
+
});
|
|
269
|
+
r.crud("member", memberEntity, {
|
|
270
|
+
write: adminWrite,
|
|
271
|
+
verbs: createOnly,
|
|
272
|
+
registerEntity: false,
|
|
273
|
+
});
|
|
274
|
+
r.crud("task", taskEntity, {
|
|
275
|
+
write: adminWrite,
|
|
276
|
+
read: openRead,
|
|
277
|
+
verbs: { ...createOnly, detail: true },
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
```
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/screens-nav. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# screens-nav
|
|
4
|
+
|
|
5
|
+
r.screen() + r.nav() — entityList/entityEdit/custom screens, cross-feature nav parents, FieldCondition<TData>, boot-validator behaviour.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Screens Nav
|
|
10
|
+
|
|
11
|
+
Register screens and navigation.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- `r.screen` + `r.nav` with cross-feature parents
|
|
16
|
+
- How the admin shell discovers the tree
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd samples/recipes/screens-nav
|
|
26
|
+
bun test
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Source files
|
|
30
|
+
|
|
31
|
+
### src/__tests__/feature.test.ts
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// Screens + Nav Showcase — Unit Test (no DB / HTTP needed).
|
|
35
|
+
// Proves the Framework surface that M1's ui-core Phase 4 + Phase 5 consume:
|
|
36
|
+
// - registry exposes screens + navs with qualified ids
|
|
37
|
+
// - cross-feature nav parents resolve
|
|
38
|
+
// - convenience indexes (getScreensByEntity / getTopLevelNavs /
|
|
39
|
+
// getNavsByParent) enable zero-reverse-index tree walks
|
|
40
|
+
// - declarative FieldCondition ({ field, eq/ne } | boolean) lands on EditFieldSpec
|
|
41
|
+
// - normalizeEditField / normalizeListColumn collapse the string shorthand
|
|
42
|
+
// - validateBoot catches the common author mistakes (unknown field,
|
|
43
|
+
// custom screen without a renderer component)
|
|
44
|
+
|
|
45
|
+
import { describe, expect, test } from "bun:test";
|
|
46
|
+
import {
|
|
47
|
+
createRegistry,
|
|
48
|
+
defineFeature,
|
|
49
|
+
isFieldsEditSection,
|
|
50
|
+
normalizeEditField,
|
|
51
|
+
normalizeListColumn,
|
|
52
|
+
validateBoot as validateBootRaw,
|
|
53
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
54
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
|
|
55
|
+
import { bookEntity, createBookshopAdminFeature, createBookshopFeature } from "../feature";
|
|
56
|
+
|
|
57
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
58
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bookshop = createBookshopFeature();
|
|
62
|
+
const bookshopAdmin = createBookshopAdminFeature();
|
|
63
|
+
const registry = createRegistry([bookshop, bookshopAdmin]);
|
|
64
|
+
|
|
65
|
+
describe("screens-nav showcase — registry state", () => {
|
|
66
|
+
test("validateBoot accepts the full registered app", () => {
|
|
67
|
+
expect(() => validateBoot([bookshop, bookshopAdmin])).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("raw validateBoot (no fixture) still enforces entityList sortable/defaultSort/i18n rules", () => {
|
|
71
|
+
expect(() => validateBootRaw([bookshop, bookshopAdmin])).toThrow(/i18n|sortable|defaultSort/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("all three screen variants registered with qualified ids", () => {
|
|
75
|
+
expect(registry.getScreen("bookshop:screen:book-list")?.id).toBe("bookshop:screen:book-list");
|
|
76
|
+
expect(registry.getScreen("bookshop:screen:book-edit")?.id).toBe("bookshop:screen:book-edit");
|
|
77
|
+
expect(registry.getScreen("bookshop-admin:screen:audit-log")?.id).toBe(
|
|
78
|
+
"bookshop-admin:screen:audit-log",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("getScreensByEntity indexes entityList + entityEdit, excludes custom", () => {
|
|
83
|
+
const byBook = registry.getScreensByEntity("book");
|
|
84
|
+
expect(byBook.map((s) => s.id).sort()).toEqual([
|
|
85
|
+
"bookshop:screen:book-edit",
|
|
86
|
+
"bookshop:screen:book-list",
|
|
87
|
+
]);
|
|
88
|
+
// Custom screens (no entity) are never indexed here.
|
|
89
|
+
for (const s of byBook) expect(s.type).not.toBe("custom");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("screens-nav showcase — nav tree", () => {
|
|
94
|
+
test("getTopLevelNavs returns only parent-less entries, qualified id", () => {
|
|
95
|
+
const tops = registry.getTopLevelNavs();
|
|
96
|
+
expect(tops.map((n) => n.id)).toEqual(["bookshop:nav:main"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("cross-feature nav parent: bookshop-admin hangs under bookshop:nav:main", () => {
|
|
100
|
+
const children = registry.getNavsByParent("bookshop:nav:main");
|
|
101
|
+
// Two direct children — one from bookshop, one from bookshop-admin.
|
|
102
|
+
// The registry preserves registration order (bookshop first, admin
|
|
103
|
+
// second), so that's how they come back; the renderer sorts by `order`
|
|
104
|
+
// at tree-assembly time.
|
|
105
|
+
expect(children.map((n) => n.id)).toEqual(["bookshop:nav:books", "bookshop-admin:nav:audit"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("nav entries preserve screen/access/icon/order from the declaration", () => {
|
|
109
|
+
const books = registry.getNav("bookshop:nav:books");
|
|
110
|
+
expect(books).toMatchObject({
|
|
111
|
+
label: "bookshop:nav.books",
|
|
112
|
+
parent: "bookshop:nav:main",
|
|
113
|
+
screen: "bookshop:screen:book-list",
|
|
114
|
+
order: 10,
|
|
115
|
+
});
|
|
116
|
+
const audit = registry.getNav("bookshop-admin:nav:audit");
|
|
117
|
+
expect(audit?.access).toEqual({ roles: ["Admin"] });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("feature.navs (unregistered) keeps the short id side-by-side with the qualified registry-side", () => {
|
|
121
|
+
// Same NavDefinition — different perspective. feature-author-side uses
|
|
122
|
+
// short ids (stable across feature-renames via auto-prefix), the
|
|
123
|
+
// registry mutates `id` to qualified for downstream consumers.
|
|
124
|
+
expect(bookshop.navs["main"]?.id).toBe("main");
|
|
125
|
+
expect(registry.getNav("bookshop:nav:main")?.id).toBe("bookshop:nav:main");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("screens-nav showcase — field specs", () => {
|
|
130
|
+
test("normalizeEditField collapses string shorthand into the object form", () => {
|
|
131
|
+
// String form: just a field reference — downstream consumers treat
|
|
132
|
+
// it the same as the full object with no overrides.
|
|
133
|
+
expect(normalizeEditField("title")).toEqual({ field: "title" });
|
|
134
|
+
// Object form is returned verbatim.
|
|
135
|
+
const condition = { field: "published", eq: true } as const;
|
|
136
|
+
expect(normalizeEditField({ field: "price", readOnly: condition, span: 1 })).toEqual({
|
|
137
|
+
field: "price",
|
|
138
|
+
readOnly: condition,
|
|
139
|
+
span: 1,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("normalizeListColumn collapses string columns too", () => {
|
|
144
|
+
expect(normalizeListColumn("author")).toEqual({ field: "author" });
|
|
145
|
+
const renderer = { format: "currency", symbol: "€" } as const;
|
|
146
|
+
expect(normalizeListColumn({ field: "price", renderer })).toEqual({
|
|
147
|
+
field: "price",
|
|
148
|
+
renderer,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("declarative FieldCondition survives registry storage intact", () => {
|
|
153
|
+
const screen = registry.getScreen("bookshop:screen:book-edit");
|
|
154
|
+
if (screen?.type !== "entityEdit") throw new Error("expected entityEdit");
|
|
155
|
+
const basicsSection = screen.layout.sections[0];
|
|
156
|
+
const publishingSection = screen.layout.sections[1];
|
|
157
|
+
if (!basicsSection || !isFieldsEditSection(basicsSection)) {
|
|
158
|
+
throw new Error("expected fields section");
|
|
159
|
+
}
|
|
160
|
+
if (!publishingSection || !isFieldsEditSection(publishingSection)) {
|
|
161
|
+
throw new Error("expected fields section");
|
|
162
|
+
}
|
|
163
|
+
const priceField = normalizeEditField(publishingSection.fields[0]!);
|
|
164
|
+
expect(priceField.readOnly).toEqual({ field: "published", eq: true });
|
|
165
|
+
const authorField = normalizeEditField(basicsSection.fields[1]!);
|
|
166
|
+
expect(authorField.visible).toEqual({ field: "published", eq: false });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("screens-nav showcase — boot-validator catches author mistakes", () => {
|
|
171
|
+
test("unknown field in entityEdit section fails boot with a useful message", () => {
|
|
172
|
+
// Typo-protection: field refs must exist on the entity. Mis-spelled
|
|
173
|
+
// column names would otherwise silently vanish from the form.
|
|
174
|
+
const broken = defineFeature("broken-shop", (r) => {
|
|
175
|
+
r.entity("book", bookEntity);
|
|
176
|
+
r.screen({
|
|
177
|
+
id: "book-edit",
|
|
178
|
+
type: "entityEdit",
|
|
179
|
+
entity: "book",
|
|
180
|
+
layout: {
|
|
181
|
+
sections: [
|
|
182
|
+
{ title: "s", fields: ["title", "tittle"] }, // typo
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
expect(() => validateBoot([broken])).toThrow(/field "tittle"/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("custom screen needs at least one platform renderer", () => {
|
|
191
|
+
// A custom screen with an empty renderer object is structurally empty
|
|
192
|
+
// — ui-core would have nothing to mount. Caught at boot.
|
|
193
|
+
const broken = defineFeature("empty-custom", (r) => {
|
|
194
|
+
r.screen({ id: "blank", type: "custom", renderer: {} });
|
|
195
|
+
});
|
|
196
|
+
expect(() => validateBoot([broken])).toThrow(/react nor a native component/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("nav with dangling screen ref fails boot", () => {
|
|
200
|
+
const broken = defineFeature("ghost-ref", (r) => {
|
|
201
|
+
r.nav({ id: "lost", label: "x", screen: "does-not:screen:exist" });
|
|
202
|
+
});
|
|
203
|
+
expect(() => validateBoot([broken])).toThrow(/references screen "does-not:screen:exist"/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### src/feature.ts
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// Screens + Nav Showcase — two features prove:
|
|
212
|
+
// 1. All three screen variants (entityList, entityEdit, custom) register
|
|
213
|
+
// through r.screen()
|
|
214
|
+
// 2. Field-level conditionals (visible/readOnly/required) use declarative
|
|
215
|
+
// FieldCondition ({ field, eq/ne } | boolean) — JSON-safe for schema
|
|
216
|
+
// injection and ui-core evaluation at render time
|
|
217
|
+
// 3. Level-4 slots + Level-2 renderers are plain-data shapes — engine
|
|
218
|
+
// stores them verbatim, ui-core resolves at mount-time
|
|
219
|
+
// 4. r.nav() flattens into a registry-wide tree whose parent refs can
|
|
220
|
+
// cross feature boundaries (bookshop-admin hangs a nav under
|
|
221
|
+
// bookshop's main group — no import, no coupling)
|
|
222
|
+
//
|
|
223
|
+
// The rendering is ui-core's job (M1 Phase 4/5); this sample focuses on
|
|
224
|
+
// the Framework surface — registration + boot-validation + registry
|
|
225
|
+
// lookups the renderer will consume.
|
|
226
|
+
|
|
227
|
+
import {
|
|
228
|
+
createBooleanField,
|
|
229
|
+
createEntity,
|
|
230
|
+
createNumberField,
|
|
231
|
+
createTextField,
|
|
232
|
+
defineFeature,
|
|
233
|
+
type FeatureDefinition,
|
|
234
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
235
|
+
|
|
236
|
+
export const bookEntity = createEntity({
|
|
237
|
+
table: "read_books",
|
|
238
|
+
fields: {
|
|
239
|
+
title: createTextField({
|
|
240
|
+
personal: false,
|
|
241
|
+
reason: "is_business_data",
|
|
242
|
+
required: true,
|
|
243
|
+
maxLength: 200,
|
|
244
|
+
}),
|
|
245
|
+
author: createTextField({
|
|
246
|
+
personal: { of: "id" },
|
|
247
|
+
find: "none",
|
|
248
|
+
required: true,
|
|
249
|
+
maxLength: 100,
|
|
250
|
+
}),
|
|
251
|
+
price: createNumberField({ required: true }),
|
|
252
|
+
published: createBooleanField({ default: false }),
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export function createBookshopFeature(): FeatureDefinition {
|
|
257
|
+
return defineFeature("bookshop", (r) => {
|
|
258
|
+
r.systemScope();
|
|
259
|
+
r.entity("book", bookEntity);
|
|
260
|
+
|
|
261
|
+
// entityList — columns carry string shorthand and the object form
|
|
262
|
+
// side by side; the framework normalizes both through
|
|
263
|
+
// normalizeListColumn() at ui-core's iteration time.
|
|
264
|
+
r.screen({
|
|
265
|
+
id: "book-list",
|
|
266
|
+
type: "entityList",
|
|
267
|
+
entity: "book",
|
|
268
|
+
columns: [
|
|
269
|
+
"title",
|
|
270
|
+
"author",
|
|
271
|
+
{
|
|
272
|
+
field: "price",
|
|
273
|
+
renderer: { format: "currency", symbol: "€" },
|
|
274
|
+
},
|
|
275
|
+
"published",
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// entityEdit — sections + spans + declarative conditionals + slots.
|
|
280
|
+
r.screen({
|
|
281
|
+
id: "book-edit",
|
|
282
|
+
type: "entityEdit",
|
|
283
|
+
entity: "book",
|
|
284
|
+
layout: {
|
|
285
|
+
sections: [
|
|
286
|
+
{
|
|
287
|
+
title: "bookshop:section.basics",
|
|
288
|
+
columns: 2,
|
|
289
|
+
// Demonstrates the declarative FieldCondition MECHANIC (data-gate
|
|
290
|
+
// on another field) — role-gates are not expressible here; use
|
|
291
|
+
// field-level access on the entity for those.
|
|
292
|
+
fields: ["title", { field: "author", visible: { field: "published", eq: false } }],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
title: "bookshop:section.publishing",
|
|
296
|
+
columns: 2,
|
|
297
|
+
fields: [
|
|
298
|
+
{
|
|
299
|
+
field: "price",
|
|
300
|
+
readOnly: { field: "published", eq: true },
|
|
301
|
+
span: 1,
|
|
302
|
+
},
|
|
303
|
+
{ field: "published", span: 1 },
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
slots: {
|
|
309
|
+
afterForm: { react: { __component: "BookHistorySidebar" } },
|
|
310
|
+
},
|
|
311
|
+
access: { roles: ["Admin", "Editor"] },
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
r.nav({
|
|
315
|
+
id: "main",
|
|
316
|
+
label: "bookshop:nav.main",
|
|
317
|
+
icon: "book-open",
|
|
318
|
+
order: 10,
|
|
319
|
+
});
|
|
320
|
+
r.nav({
|
|
321
|
+
id: "books",
|
|
322
|
+
label: "bookshop:nav.books",
|
|
323
|
+
icon: "list",
|
|
324
|
+
parent: "bookshop:nav:main",
|
|
325
|
+
screen: "bookshop:screen:book-list",
|
|
326
|
+
order: 10,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function createBookshopAdminFeature(): FeatureDefinition {
|
|
332
|
+
return defineFeature("bookshop-admin", (r) => {
|
|
333
|
+
r.systemScope();
|
|
334
|
+
|
|
335
|
+
r.screen({
|
|
336
|
+
id: "audit-log",
|
|
337
|
+
type: "custom",
|
|
338
|
+
renderer: { react: { __component: "AuditLogScreen" } },
|
|
339
|
+
access: { roles: ["Admin"] },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
r.nav({
|
|
343
|
+
id: "audit",
|
|
344
|
+
label: "bookshop-admin:nav.audit",
|
|
345
|
+
icon: "shield",
|
|
346
|
+
parent: "bookshop:nav:main",
|
|
347
|
+
screen: "bookshop-admin:screen:audit-log",
|
|
348
|
+
access: { roles: ["Admin"] },
|
|
349
|
+
order: 20,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
```
|