@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.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. 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
+ ```