@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,401 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/tags-basic. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# tags-basic
|
|
4
|
+
|
|
5
|
+
Tag and group any entity without changing it — host-agnostic, event-sourced.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Tags
|
|
10
|
+
|
|
11
|
+
Tag and group **any** entity — without adding a column to it, writing a
|
|
12
|
+
migration, or touching its feature. The recipe creates a plain `note`
|
|
13
|
+
entity that knows nothing about tags, then tags and groups notes through
|
|
14
|
+
the `tags` bundle alone.
|
|
15
|
+
|
|
16
|
+
The result: a note can carry any number of tags, you can ask "which tags
|
|
17
|
+
does this note have?" and "which notes carry this tag?", and the `note`
|
|
18
|
+
row stays exactly `{ id, title }`.
|
|
19
|
+
|
|
20
|
+
## What it shows
|
|
21
|
+
|
|
22
|
+
- **Zero host changes** — `note` has no tag column, no `wireTagsFor`, no
|
|
23
|
+
tag awareness. Tagging works anyway, because the tags feature owns its
|
|
24
|
+
own tables.
|
|
25
|
+
- **`tags:write:create-tag`** — adds a tag to the tenant's catalog
|
|
26
|
+
(`read_tags`). Takes an optional `color` (rendered as a colored chip by
|
|
27
|
+
the web UI) and an optional `scope` (empty = global, or an entity type to
|
|
28
|
+
restrict it to). Returns the tag's id.
|
|
29
|
+
- **`tags:write:update-tag` / `delete-tag`** — rename / recolor / re-scope a
|
|
30
|
+
tag (optimistic-locked), or delete it (cascades over its assignments).
|
|
31
|
+
- **`tags:write:assign-tag` / `remove-tag`** — link/unlink a tag and an
|
|
32
|
+
entity by `{ tagId, entityType, entityId }`. Both are idempotent:
|
|
33
|
+
re-assigning is a no-op, removing a non-existent link still succeeds.
|
|
34
|
+
- **Read-layer composition, no JOIN** — "tags of a note" lists
|
|
35
|
+
`tag-assignment` filtered by `entityId`; "notes with a tag" filters by
|
|
36
|
+
`tagId`. The app composes the two reads; there is no relational pivot.
|
|
37
|
+
|
|
38
|
+
## Using it — the tag flow
|
|
39
|
+
|
|
40
|
+
You use the bundle by dispatching its handlers; nothing is wired into the
|
|
41
|
+
tagged entity. A host needs just two calls — `write` and `query` — which any
|
|
42
|
+
app dispatcher provides. The flow below is embedded from `usage.ts` and is
|
|
43
|
+
run end-to-end against the real dispatcher + DB by this recipe's integration
|
|
44
|
+
test (`the documented tagFlow runs …`):
|
|
45
|
+
|
|
46
|
+
```ts file=<rootDir>/_samples/recipes-tags-basic/src/usage.ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Web UI — the drop-in `<TagSection>`
|
|
50
|
+
|
|
51
|
+
You don't have to hand-build a tag UI. The feature ships one from its client
|
|
52
|
+
subpath `@cosmicdrift/kumiko-bundled-features/tags/web`: `<TagSection>` takes an
|
|
53
|
+
`entityName` + `entityId`, shows that entity's tags, and lets the user attach an
|
|
54
|
+
existing tag, create-and-attach a new one, or detach — calling the same handlers
|
|
55
|
+
as above. Register `tagsClient()` once (for its component + i18n), then mount it
|
|
56
|
+
either way:
|
|
57
|
+
|
|
58
|
+
```tsx illustration
|
|
59
|
+
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";
|
|
60
|
+
import { tagsClient, TagSection, TAGS_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
|
|
61
|
+
|
|
62
|
+
// once, at app boot — required even for standalone use (registers i18n):
|
|
63
|
+
createKumikoApp({ clientFeatures: [tagsClient()] });
|
|
64
|
+
|
|
65
|
+
// standalone — drop it into any screen, no entityEdit screen needed:
|
|
66
|
+
<TagSection entityName="note" entityId={noteId} />
|
|
67
|
+
|
|
68
|
+
// or as an extension section in an entityEdit screen schema:
|
|
69
|
+
{ kind: "extension", title: "Tags", component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } } }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The component itself is `tags/web/tag-section.tsx`
|
|
73
|
+
([source](https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/packages/bundled-features/src/tags/web/tag-section.tsx))
|
|
74
|
+
and is covered by a unit test that asserts the exact handlers it dispatches.
|
|
75
|
+
|
|
76
|
+
`tagsClient()` also ships the rest of the GitLab-style surface: a standalone
|
|
77
|
+
`tag-list` **management screen** (`TagManager` — the whole catalog with colors,
|
|
78
|
+
scopes, and usage counts), a `<TagFilter>` header-slot control that filters any
|
|
79
|
+
`entityList` by tag, and a read-only `<EntityTags>` chip row for cards/detail.
|
|
80
|
+
See the [tags feature reference](/en/bundled-features/tags/) for the full set.
|
|
81
|
+
|
|
82
|
+
## Feature composition
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
tags → core bundle: tag + tag-assignment entities,
|
|
86
|
+
create/assign/remove handlers, list queries
|
|
87
|
+
note-management → our feature: a plain `note` entity. Declares
|
|
88
|
+
r.requires("tags") only so the bundle is mounted —
|
|
89
|
+
the note itself is completely tag-agnostic.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Why it's event-sourced, not a pivot table
|
|
93
|
+
|
|
94
|
+
Kumiko is event-sourced: there are no relational pivots queried by JOIN.
|
|
95
|
+
The `tag-assignment` entity is a feature-owned, event-sourced join row
|
|
96
|
+
keyed by `(entityType, entityId)`, and the framework projects it into
|
|
97
|
+
`read_tag_assignments` from its own events. A deterministic aggregate-id
|
|
98
|
+
per `(tenant, tag, entity)` makes assigning idempotent. Cross-entity
|
|
99
|
+
views (a note's tags, a tag's notes) are assembled by reading that
|
|
100
|
+
projection and composing in the app — never by joining across aggregates.
|
|
101
|
+
|
|
102
|
+
## Run
|
|
103
|
+
|
|
104
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Source files
|
|
111
|
+
|
|
112
|
+
### src/__tests__/feature.integration.test.ts
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// Tags Basic — integration test.
|
|
116
|
+
//
|
|
117
|
+
// Proves the host-agnostic tagging flow via the real dispatcher + DB:
|
|
118
|
+
// 1. a plain `note` is created (the note feature knows nothing about tags)
|
|
119
|
+
// 2. a tag is created in the catalog
|
|
120
|
+
// 3. the tag is assigned to the note by (entityType, entityId)
|
|
121
|
+
// 4. "tags of this note" and "notes with this tag" both read from
|
|
122
|
+
// tag-assignment — read-layer composition, no JOIN, no column on `note`
|
|
123
|
+
// 5. removing the tag detaches it
|
|
124
|
+
//
|
|
125
|
+
// This is the smallest evidence that an entity needs zero tag-wiring.
|
|
126
|
+
|
|
127
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
128
|
+
import {
|
|
129
|
+
createTagsFeature,
|
|
130
|
+
tagAssignmentEntity,
|
|
131
|
+
tagEntity,
|
|
132
|
+
} from "@cosmicdrift/kumiko-bundled-features/tags";
|
|
133
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
134
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
135
|
+
import {
|
|
136
|
+
createTestUser,
|
|
137
|
+
setupTestStack,
|
|
138
|
+
type TestStack,
|
|
139
|
+
unsafeCreateEntityTable,
|
|
140
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
141
|
+
import { noteEntity, noteFeature } from "../feature";
|
|
142
|
+
import { type TagClient, tagFlow } from "../usage";
|
|
143
|
+
|
|
144
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
145
|
+
|
|
146
|
+
// Adapt the test stack to the host-facing TagClient the docs embed uses.
|
|
147
|
+
const tagClient: TagClient = {
|
|
148
|
+
write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
|
|
149
|
+
query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
|
|
150
|
+
};
|
|
151
|
+
const tags = createTagsFeature();
|
|
152
|
+
|
|
153
|
+
let stack: TestStack;
|
|
154
|
+
|
|
155
|
+
beforeAll(async () => {
|
|
156
|
+
stack = await setupTestStack({ features: [tags, noteFeature] });
|
|
157
|
+
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
158
|
+
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
159
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
160
|
+
await createEventsTable(stack.db);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
afterAll(async () => {
|
|
164
|
+
await stack.cleanup();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
beforeEach(async () => {
|
|
168
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
169
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_sample_tags_notes");
|
|
170
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
|
|
171
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
async function createNote(id: string, title: string) {
|
|
175
|
+
return stack.http.writeOk("note-management:write:note:create", { id, title }, admin);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function createTag(name: string): Promise<string> {
|
|
179
|
+
const tag = await stack.http.writeOk<{ id: string }>("tags:write:create-tag", { name }, admin);
|
|
180
|
+
return tag.id;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function assignTag(tagId: string, entityId: string) {
|
|
184
|
+
return stack.http.writeOk(
|
|
185
|
+
"tags:write:assign-tag",
|
|
186
|
+
{ tagId, entityType: "note", entityId },
|
|
187
|
+
admin,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read-layer composition: list assignments filtered on entityId → the note's
|
|
192
|
+
// tag ids. No JOIN, no tag column on `note`.
|
|
193
|
+
async function tagIdsOfNote(entityId: string): Promise<string[]> {
|
|
194
|
+
const res = await stack.http.queryOk<{ rows: Array<{ tagId: string }> }>(
|
|
195
|
+
"tags:query:tag-assignment:list",
|
|
196
|
+
{ filter: { field: "entityId", op: "eq", value: entityId } },
|
|
197
|
+
admin,
|
|
198
|
+
);
|
|
199
|
+
return res.rows.map((r) => r.tagId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
describe("tags-basic recipe — assign + compose", () => {
|
|
203
|
+
// Exercises the exact code embedded on the docs page (usage.ts → tagFlow).
|
|
204
|
+
test("the documented tagFlow runs create → assign → compose → remove", async () => {
|
|
205
|
+
const noteId = "55555555-5555-4000-8000-000000000005";
|
|
206
|
+
await createNote(noteId, "Documented flow");
|
|
207
|
+
|
|
208
|
+
const result = await tagFlow(tagClient, noteId);
|
|
209
|
+
|
|
210
|
+
expect(result.tagsOfNote).toEqual([result.tagId]);
|
|
211
|
+
expect(result.notesWithTag).toEqual([noteId]);
|
|
212
|
+
// tagFlow detaches at the end → the note carries no tags afterwards.
|
|
213
|
+
expect(await tagIdsOfNote(noteId)).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a note carries a tag without any tag-column on the note", async () => {
|
|
217
|
+
const noteId = "11111111-1111-4000-8000-000000000001";
|
|
218
|
+
await createNote(noteId, "Quarterly review");
|
|
219
|
+
const tagId = await createTag("important");
|
|
220
|
+
|
|
221
|
+
await assignTag(tagId, noteId);
|
|
222
|
+
|
|
223
|
+
expect(await tagIdsOfNote(noteId)).toEqual([tagId]);
|
|
224
|
+
|
|
225
|
+
// The note row itself stays a plain { id, title } — no tag leakage.
|
|
226
|
+
const notes = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
227
|
+
"note-management:query:note:list",
|
|
228
|
+
{},
|
|
229
|
+
admin,
|
|
230
|
+
);
|
|
231
|
+
const note = notes.rows.find((r) => r["id"] === noteId);
|
|
232
|
+
expect(note?.["title"]).toBe("Quarterly review");
|
|
233
|
+
expect(note).not.toHaveProperty("tagId");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("one tag spans multiple notes (entities-with-tag direction)", async () => {
|
|
237
|
+
await createNote("22222222-2222-4000-8000-000000000002", "Note A");
|
|
238
|
+
await createNote("33333333-3333-4000-8000-000000000003", "Note B");
|
|
239
|
+
const tagId = await createTag("project-x");
|
|
240
|
+
|
|
241
|
+
await assignTag(tagId, "22222222-2222-4000-8000-000000000002");
|
|
242
|
+
await assignTag(tagId, "33333333-3333-4000-8000-000000000003");
|
|
243
|
+
|
|
244
|
+
const res = await stack.http.queryOk<{ rows: Array<{ entityId: string }> }>(
|
|
245
|
+
"tags:query:tag-assignment:list",
|
|
246
|
+
{ filter: { field: "tagId", op: "eq", value: tagId } },
|
|
247
|
+
admin,
|
|
248
|
+
);
|
|
249
|
+
expect(res.rows.map((r) => r.entityId).sort()).toEqual([
|
|
250
|
+
"22222222-2222-4000-8000-000000000002",
|
|
251
|
+
"33333333-3333-4000-8000-000000000003",
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("removing the tag detaches it from the note", async () => {
|
|
256
|
+
const noteId = "44444444-4444-4000-8000-000000000004";
|
|
257
|
+
await createNote(noteId, "Temp");
|
|
258
|
+
const tagId = await createTag("temp-tag");
|
|
259
|
+
await assignTag(tagId, noteId);
|
|
260
|
+
expect(await tagIdsOfNote(noteId)).toEqual([tagId]);
|
|
261
|
+
|
|
262
|
+
await stack.http.writeOk(
|
|
263
|
+
"tags:write:remove-tag",
|
|
264
|
+
{ tagId, entityType: "note", entityId: noteId },
|
|
265
|
+
admin,
|
|
266
|
+
);
|
|
267
|
+
expect(await tagIdsOfNote(noteId)).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### src/feature.ts
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// kumiko-feature-version: 1
|
|
276
|
+
// Tags Basic Sample
|
|
277
|
+
//
|
|
278
|
+
// Shows the whole point of the `tags` bundle: tagging an entity needs ZERO
|
|
279
|
+
// changes to that entity. The `note` entity below has no tag column, no
|
|
280
|
+
// `wireTagsFor`, no awareness of tags at all — yet notes can be tagged and
|
|
281
|
+
// grouped, because the tags feature owns its own tables (read_tags +
|
|
282
|
+
// read_tag_assignments) and keys assignments by (entityType, entityId).
|
|
283
|
+
//
|
|
284
|
+
// Flow (see the integration test):
|
|
285
|
+
// 1. App-author defines a plain `note` entity — nothing tag-specific.
|
|
286
|
+
// 2. A tenant creates a tag via `tags:write:create-tag`.
|
|
287
|
+
// 3. The tag is attached to a note via `tags:write:assign-tag`
|
|
288
|
+
// with { tagId, entityType: "note", entityId: <noteId> }.
|
|
289
|
+
// 4. "Which tags does this note have?" / "Which notes carry this tag?"
|
|
290
|
+
// are read-layer compositions: list `tag-assignment` filtered by
|
|
291
|
+
// entityId or tagId — no JOIN, no column on `note`.
|
|
292
|
+
|
|
293
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
294
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
295
|
+
import { z } from "zod";
|
|
296
|
+
|
|
297
|
+
// --- Entity ---
|
|
298
|
+
//
|
|
299
|
+
// A plain entity. Note there is NOTHING here that mentions tags — that is the
|
|
300
|
+
// feature's promise: any entity is taggable as-is.
|
|
301
|
+
|
|
302
|
+
export const noteEntity = createEntity({
|
|
303
|
+
table: "read_sample_tags_notes",
|
|
304
|
+
fields: {
|
|
305
|
+
title: createTextField({
|
|
306
|
+
personal: false,
|
|
307
|
+
reason: "is_business_data",
|
|
308
|
+
required: true,
|
|
309
|
+
maxLength: 200,
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const noteTable = buildEntityTable("note", noteEntity);
|
|
315
|
+
|
|
316
|
+
const noteExecutor = createEventStoreExecutor(noteTable, noteEntity, { entityName: "note" });
|
|
317
|
+
|
|
318
|
+
// --- Feature ---
|
|
319
|
+
|
|
320
|
+
export const noteFeature = defineFeature("note-management", (r) => {
|
|
321
|
+
// tags is non-optional for this recipe: the demo tags notes. The note
|
|
322
|
+
// feature itself stays completely tag-agnostic — it only declares the
|
|
323
|
+
// dependency so the bundle is mounted.
|
|
324
|
+
r.requires("tags");
|
|
325
|
+
|
|
326
|
+
r.entity("note", noteEntity);
|
|
327
|
+
|
|
328
|
+
r.writeHandler({
|
|
329
|
+
name: "note:create",
|
|
330
|
+
schema: z.object({ id: z.string(), title: z.string() }),
|
|
331
|
+
access: { roles: ["TenantAdmin"] },
|
|
332
|
+
handler: async (event, ctx) =>
|
|
333
|
+
noteExecutor.create({ id: event.payload.id, title: event.payload.title }, event.user, ctx.db),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
r.queryHandler({
|
|
337
|
+
name: "note:list",
|
|
338
|
+
schema: z.object({}),
|
|
339
|
+
access: { roles: ["TenantAdmin"] },
|
|
340
|
+
handler: async (_query, ctx) => {
|
|
341
|
+
const rows = await ctx.db.selectMany(noteTable);
|
|
342
|
+
return { rows };
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### src/usage.ts
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
// Tags Basic — using the bundle.
|
|
352
|
+
//
|
|
353
|
+
// The tags feature is driven entirely by dispatching its handlers; nothing is
|
|
354
|
+
// wired into the tagged entity. A host needs exactly two calls — write and
|
|
355
|
+
// query — which any app dispatcher provides. This recipe's integration test
|
|
356
|
+
// runs `tagFlow` below against the real dispatcher + DB.
|
|
357
|
+
|
|
358
|
+
// The minimal surface the tag flow needs from a host dispatcher. An app's
|
|
359
|
+
// client satisfies this; the integration test adapts the test stack to it.
|
|
360
|
+
export type TagClient = {
|
|
361
|
+
write: <T>(type: string, payload: unknown) => Promise<T>;
|
|
362
|
+
query: <T>(type: string, payload: unknown) => Promise<T>;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Create a tag, attach it to a note, read it from both directions, detach it.
|
|
366
|
+
export async function tagFlow(client: TagClient, noteId: string) {
|
|
367
|
+
// 1. Create a tag in the tenant catalog → returns its id. A tag carries a
|
|
368
|
+
// `color` (rendered as a colored chip by the web UI) and an optional
|
|
369
|
+
// `scope`: empty = global (offer on every entity), or an entity type to
|
|
370
|
+
// restrict it — here "note", so the picker only suggests it on notes.
|
|
371
|
+
const { id: tagId } = await client.write<{ id: string }>("tags:write:create-tag", {
|
|
372
|
+
name: "important",
|
|
373
|
+
color: "#2563eb",
|
|
374
|
+
scope: "note",
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// 2. Attach it to ANY entity by (type, id) — no column on that entity
|
|
378
|
+
await client.write("tags:write:assign-tag", { tagId, entityType: "note", entityId: noteId });
|
|
379
|
+
|
|
380
|
+
// 3a. "Which tags does this note have?" — filter assignments by entityId
|
|
381
|
+
const ofNote = await client.query<{ rows: Array<{ tagId: string }> }>(
|
|
382
|
+
"tags:query:tag-assignment:list",
|
|
383
|
+
{ filter: { field: "entityId", op: "eq", value: noteId } },
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// 3b. "Which notes carry this tag?" — filter by tagId (no JOIN)
|
|
387
|
+
const withTag = await client.query<{ rows: Array<{ entityId: string }> }>(
|
|
388
|
+
"tags:query:tag-assignment:list",
|
|
389
|
+
{ filter: { field: "tagId", op: "eq", value: tagId } },
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// 4. Detach it (idempotent — removing a missing link still succeeds)
|
|
393
|
+
await client.write("tags:write:remove-tag", { tagId, entityType: "note", entityId: noteId });
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
tagId,
|
|
397
|
+
tagsOfNote: ofNote.rows.map((r) => r.tagId),
|
|
398
|
+
notesWithTag: withTag.rows.map((r) => r.entityId),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
```
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/tenant-isolation. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# tenant-isolation
|
|
4
|
+
|
|
5
|
+
Multi-tenant separation. Your data stays your data.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Tenant Isolation
|
|
10
|
+
|
|
11
|
+
Multi-tenant data isolation by default.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Tenant filter on ordinary handlers
|
|
16
|
+
- What breaks if you forget tenancy (and how the framework prevents it)
|
|
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/tenant-isolation
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Tenant Isolation Sample — Integration Test
|
|
37
|
+
// Proves: tenant_id scopes all data, cross-tenant access returns nothing
|
|
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 {
|
|
43
|
+
createTestUser,
|
|
44
|
+
setupTestStack,
|
|
45
|
+
type TestStack,
|
|
46
|
+
TestUsers,
|
|
47
|
+
unsafeCreateEntityTable,
|
|
48
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
49
|
+
import { noteEntity, noteFeature } from "../feature";
|
|
50
|
+
|
|
51
|
+
let stack: TestStack;
|
|
52
|
+
|
|
53
|
+
const tenantAAdmin = TestUsers.admin;
|
|
54
|
+
const tenantBAdmin = createTestUser({ id: 2, tenantId: "00000000-0000-4000-8000-000000000002" });
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
stack = await setupTestStack({ features: [noteFeature] });
|
|
58
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
59
|
+
await createEventsTable(stack.db);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await stack.cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
stack.events.reset();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("tenant isolation", () => {
|
|
71
|
+
test("tenant A cannot see tenant B's data via detail", async () => {
|
|
72
|
+
const noteA = await stack.http.writeOk<SaveContext>(
|
|
73
|
+
"notes:write:note:create",
|
|
74
|
+
{
|
|
75
|
+
title: "Tenant A secret",
|
|
76
|
+
content: "Confidential",
|
|
77
|
+
},
|
|
78
|
+
tenantAAdmin,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Tenant B tries to read Tenant A's note
|
|
82
|
+
const detail = await stack.http.queryOk<null>(
|
|
83
|
+
"notes:query:note:detail",
|
|
84
|
+
{ id: noteA.id },
|
|
85
|
+
tenantBAdmin,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(detail).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("tenant B cannot see tenant A's data in list", async () => {
|
|
92
|
+
await stack.http.writeOk<SaveContext>(
|
|
93
|
+
"notes:write:note:create",
|
|
94
|
+
{
|
|
95
|
+
title: "Only-For-A",
|
|
96
|
+
},
|
|
97
|
+
tenantAAdmin,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Tenant B lists — should not see Tenant A's notes
|
|
101
|
+
const listB = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
102
|
+
"notes:query:note:list",
|
|
103
|
+
{},
|
|
104
|
+
tenantBAdmin,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(listB.rows.some((r) => r["title"] === "Only-For-A")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("each tenant sees only their own data", async () => {
|
|
111
|
+
await stack.http.writeOk<SaveContext>(
|
|
112
|
+
"notes:write:note:create",
|
|
113
|
+
{ title: "A-Note" },
|
|
114
|
+
tenantAAdmin,
|
|
115
|
+
);
|
|
116
|
+
await stack.http.writeOk<SaveContext>(
|
|
117
|
+
"notes:write:note:create",
|
|
118
|
+
{ title: "B-Note" },
|
|
119
|
+
tenantBAdmin,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const listA = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
123
|
+
"notes:query:note:list",
|
|
124
|
+
{},
|
|
125
|
+
tenantAAdmin,
|
|
126
|
+
);
|
|
127
|
+
const listB = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
128
|
+
"notes:query:note:list",
|
|
129
|
+
{},
|
|
130
|
+
tenantBAdmin,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(listA.rows.some((r) => r["title"] === "A-Note")).toBe(true);
|
|
134
|
+
expect(listA.rows.some((r) => r["title"] === "B-Note")).toBe(false);
|
|
135
|
+
|
|
136
|
+
expect(listB.rows.some((r) => r["title"] === "B-Note")).toBe(true);
|
|
137
|
+
expect(listB.rows.some((r) => r["title"] === "A-Note")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### src/feature.ts
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
146
|
+
|
|
147
|
+
export const noteEntity = createEntity({
|
|
148
|
+
table: "read_sample_notes",
|
|
149
|
+
fields: {
|
|
150
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
151
|
+
content: createTextField({ personal: false, reason: "is_business_data" }),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const adminWrite = { access: { roles: ["Admin"] } } as const;
|
|
156
|
+
const openRead = {
|
|
157
|
+
access: {
|
|
158
|
+
openToAll: {
|
|
159
|
+
reason:
|
|
160
|
+
"any signed-in user may list and view notes in their own tenant (tenant " +
|
|
161
|
+
"isolation, not per-user); writes are still gated by adminWrite",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
} as const;
|
|
165
|
+
|
|
166
|
+
export const noteFeature = defineFeature("notes", (r) => {
|
|
167
|
+
r.crud("note", noteEntity, {
|
|
168
|
+
write: adminWrite,
|
|
169
|
+
read: openRead,
|
|
170
|
+
verbs: { update: false, delete: false, restore: false },
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
```
|