@cosmicdrift/kumiko-samples 0.388.0 → 0.389.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 (129) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +42 -26
  6. package/packages/bundled-features/src/audit/feature.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  9. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  14. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  16. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  17. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  18. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  19. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  20. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  22. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  23. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  24. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  25. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  26. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  27. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  28. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  29. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +20 -7
  30. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  31. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  32. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  33. package/packages/bundled-features/src/config/changes.json +6 -0
  34. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  35. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  36. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +1 -1
  37. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  38. package/packages/bundled-features/src/delivery/changes.json +12 -0
  39. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  40. package/packages/bundled-features/src/delivery/index.ts +5 -0
  41. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  42. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  43. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  44. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  45. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  46. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  47. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  48. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  50. package/packages/bundled-features/src/jobs/feature.ts +1 -0
  51. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  52. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  53. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  54. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  55. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  56. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  59. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  60. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  61. package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -0
  62. package/packages/bundled-features/src/secrets/changes.json +6 -0
  63. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  64. package/packages/bundled-features/src/sessions/changes.json +7 -0
  65. package/packages/bundled-features/src/sessions/feature.ts +1 -0
  66. package/packages/bundled-features/src/sessions/index.ts +1 -1
  67. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  68. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  69. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  70. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  71. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  72. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  73. package/packages/bundled-features/src/tags/feature.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/changes.json +36 -0
  75. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  76. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  78. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +30 -5
  79. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  80. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  81. package/packages/bundled-features/src/template-resolver/seeding.ts +10 -3
  82. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +52 -8
  83. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  84. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  85. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  86. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  87. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  88. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  89. package/packages/bundled-features/src/user/feature.ts +1 -0
  90. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  91. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  92. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  93. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  94. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  95. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  96. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  97. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +27 -8
  98. package/packages/bundled-features/src/waitlist/import.ts +23 -3
  99. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +2 -1
  100. package/packages/bundled-features/src/waitlist/options.ts +2 -1
  101. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  102. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  103. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  104. package/samples/apps/styleguide/src/app/server.ts +4 -1
  105. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  106. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  107. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  108. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  109. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  110. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  111. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  112. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  113. package/samples/apps/use-all-bundled/feature-manifest.json +16 -0
  114. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  115. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  116. package/samples/apps/use-all-bundled/package.json +2 -1
  117. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  118. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  119. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  120. package/samples/recipes/golden-path/src/feature.ts +2 -1
  121. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  122. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  123. package/samples/recipes/list-table-options/README.md +50 -0
  124. package/samples/recipes/list-table-options/package.json +11 -0
  125. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  126. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  127. package/samples/recipes/state-machine/src/events.ts +3 -2
  128. package/samples/recipes/tenant-isolation/README.md +1 -0
  129. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
@@ -8,6 +8,7 @@ import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
8
8
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
10
10
  import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
11
+ import { BUNDLED_LISTS } from "../src/bundled-lists";
11
12
  import {
12
13
  APP_FEATURES,
13
14
  AUTH_COMPOSE_OPTIONS,
@@ -27,6 +28,7 @@ await runProdApp({
27
28
  featureToggles: true,
28
29
  envSchema,
29
30
  validateBootOptions: VALIDATE_BOOT_OPTIONS,
31
+ bundledLists: BUNDLED_LISTS,
30
32
  // Smoke-only: exercises the hard PII boot gate + blind-index gate with
31
33
  // every bundled feature mounted. Real apps wire createPgKmsAdapter(...).
32
34
  kms: new InMemoryKmsAdapter(),
@@ -71,6 +71,7 @@
71
71
  "name": "audit",
72
72
  "description": "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection — the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: \"system\"` to read the app-instance system events such as `app.started`, and reads the events of every tenant (system tenant excluded) through `audit:query:list-all-tenants` and the `audit-log-all-tenants` screen. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
73
73
  "toggleableDefault": null,
74
+ "adoptsBundledLists": true,
74
75
  "requires": [
75
76
  "tenant",
76
77
  "user",
@@ -232,6 +233,7 @@
232
233
  "validated": false,
233
234
  "options": [
234
235
  "optional",
236
+ "systemAdmins",
235
237
  "admins",
236
238
  "all"
237
239
  ],
@@ -339,6 +341,7 @@
339
341
  "name": "cap-counter",
340
342
  "description": "Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
341
343
  "toggleableDefault": null,
344
+ "adoptsBundledLists": true,
342
345
  "requires": [],
343
346
  "optionalRequires": [],
344
347
  "configReads": [],
@@ -362,6 +365,7 @@
362
365
  "name": "cap-overview",
363
366
  "description": "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; tenant admins (or the roles in `usageVisibleTo`) get their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
364
367
  "toggleableDefault": null,
368
+ "adoptsBundledLists": true,
365
369
  "requires": [
366
370
  "tenant",
367
371
  "tier-engine",
@@ -744,6 +748,7 @@
744
748
  "name": "delivery",
745
749
  "description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
746
750
  "toggleableDefault": null,
751
+ "adoptsBundledLists": true,
747
752
  "requires": [
748
753
  "tenant"
749
754
  ],
@@ -895,6 +900,7 @@
895
900
  "name": "feature-toggles",
896
901
  "description": "Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: \"shared\"` syncs the snapshot across instances whenever a `toggle-set` event is appended.",
897
902
  "toggleableDefault": null,
903
+ "adoptsBundledLists": true,
898
904
  "requires": [],
899
905
  "optionalRequires": [],
900
906
  "configReads": [],
@@ -1372,6 +1378,7 @@
1372
1378
  "name": "inbound-mail-foundation",
1373
1379
  "description": "Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.",
1374
1380
  "toggleableDefault": null,
1381
+ "adoptsBundledLists": true,
1375
1382
  "requires": [
1376
1383
  "tenant-lifecycle"
1377
1384
  ],
@@ -1464,6 +1471,7 @@
1464
1471
  "name": "jobs",
1465
1472
  "description": "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.",
1466
1473
  "toggleableDefault": null,
1474
+ "adoptsBundledLists": true,
1467
1475
  "requires": [],
1468
1476
  "optionalRequires": [],
1469
1477
  "configReads": [],
@@ -1759,6 +1767,7 @@
1759
1767
  "name": "managed-pages",
1760
1768
  "description": "Tenant-editable, server-rendered public pages with per-tenant branding. Stores one Markdown `page` per `(tenantId, slug, lang)` in the `read_pages` entity table with a `published` gate plus `description`/`ogImage` SEO meta. Registers an anonymous `GET {basePath}/:slug` route that resolves the tenant from the request Host via the app-supplied `resolveApexTenant`, serves only published pages (drafts → 404), renders Markdown through the hardened `page-render` core, and isolates per-tenant content with `Vary: Host`. Ships TenantAdmin/SystemAdmin admin screens (`entityList` + `entityEdit`) backed by convention CRUD handlers (`managed-pages:write:page:{create,update,delete}`, `managed-pages:query:page:{list,detail}`); the app wires nav/workspace onto `managed-pages:screen:page-list`. Also exposes `managed-pages:query:by-tenant-published` (anonymous, SQL-filtered on `published`) for sitemap/discovery consumers such as the `seo` feature. Branding (via `config`, scope tenant): `branding-{title,description,site-url,accent-color,logo-url,layout-preset,custom-css}` keys with write-time validation (hex color, https URLs), a `configEdit` self-service screen (`managed-pages:screen:branding-settings`), and a `managed-pages:query:branding` read that the render path applies as scoped `:root` CSS vars + a logo/title header. Also exposes `managed-pages:write:set` (idempotent slug-keyed upsert, SystemAdmin cross-tenant via `tenantIdOverride`) as a provisioning API. Requires `config` + `anonymousAccess` wired at app bootstrap.",
1761
1769
  "toggleableDefault": null,
1770
+ "adoptsBundledLists": true,
1762
1771
  "requires": [
1763
1772
  "config"
1764
1773
  ],
@@ -2088,6 +2097,7 @@
2088
2097
  "name": "personal-access-tokens",
2089
2098
  "description": "Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.",
2090
2099
  "toggleableDefault": null,
2100
+ "adoptsBundledLists": true,
2091
2101
  "requires": [
2092
2102
  "user",
2093
2103
  "tenant",
@@ -2417,6 +2427,7 @@
2417
2427
  "name": "sessions",
2418
2428
  "description": "Tracks signed-in clients in the `store_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the same callbacks are also registered as an auth-foundation sessionStore provider, resolvable generically via `resolveSessionStore()`. The feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
2419
2429
  "toggleableDefault": null,
2430
+ "adoptsBundledLists": true,
2420
2431
  "requires": [
2421
2432
  "user",
2422
2433
  "auth-foundation"
@@ -2711,6 +2722,7 @@
2711
2722
  "name": "tags",
2712
2723
  "description": "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
2713
2724
  "toggleableDefault": null,
2725
+ "adoptsBundledLists": true,
2714
2726
  "requires": [],
2715
2727
  "optionalRequires": [],
2716
2728
  "configReads": [],
@@ -2826,6 +2838,7 @@
2826
2838
  "name": "tenant",
2827
2839
  "description": "Registers the three core multi-tenancy entities — `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) — along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
2828
2840
  "toggleableDefault": null,
2841
+ "adoptsBundledLists": true,
2829
2842
  "requires": [
2830
2843
  "config"
2831
2844
  ],
@@ -3192,6 +3205,7 @@
3192
3205
  "name": "user",
3193
3206
  "description": "Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` — membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
3194
3207
  "toggleableDefault": null,
3208
+ "adoptsBundledLists": true,
3195
3209
  "requires": [],
3196
3210
  "optionalRequires": [],
3197
3211
  "configReads": [],
@@ -3219,6 +3233,7 @@
3219
3233
  "name": "user-data-rights",
3220
3234
  "description": "Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` → ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, \"<entity>\", { export, delete })` — the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When `mail-foundation` and a `mail-transport-*` are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.",
3221
3235
  "toggleableDefault": null,
3236
+ "adoptsBundledLists": true,
3222
3237
  "requires": [
3223
3238
  "user",
3224
3239
  "data-retention",
@@ -3386,6 +3401,7 @@
3386
3401
  "name": "waitlist",
3387
3402
  "description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
3388
3403
  "toggleableDefault": null,
3404
+ "adoptsBundledLists": true,
3389
3405
  "requires": [
3390
3406
  "tenant",
3391
3407
  "auth-email-password",
@@ -11,6 +11,7 @@
11
11
  import { collectTableMetas, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
12
12
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
14
+ import { BUNDLED_LISTS } from "../src/bundled-lists";
14
15
  import { APP_FEATURES, AUTH_COMPOSE_OPTIONS, VALIDATE_BOOT_OPTIONS } from "../src/run-config";
15
16
 
16
17
  export { VALIDATE_BOOT_OPTIONS };
@@ -18,6 +19,7 @@ export { VALIDATE_BOOT_OPTIONS };
18
19
  export const FEATURES: readonly FeatureDefinition[] = composeFeatures(APP_FEATURES, {
19
20
  includeBundled: true,
20
21
  authOptions: AUTH_COMPOSE_OPTIONS,
22
+ bundledLists: BUNDLED_LISTS,
21
23
  });
22
24
 
23
25
  // collectTableMetas erfasst neben entities auch
@@ -12,11 +12,13 @@ import {
12
12
  } from "@cosmicdrift/kumiko-server-runtime/compose-features";
13
13
  import { notesFeature } from "./src/app/notes-feature";
14
14
  import { appScreensFeature } from "./src/app/screens-feature";
15
+ import { BUNDLED_LISTS } from "./src/bundled-lists";
15
16
  import { APP_FEATURES, AUTH_COMPOSE_OPTIONS } from "./src/run-config";
16
17
 
17
18
  export default {
18
19
  features: composeFeatures([...APP_FEATURES, appScreensFeature, notesFeature], {
19
20
  includeBundled: true,
21
+ bundledLists: BUNDLED_LISTS,
20
22
  ...(buildComposeAuthOptions(AUTH_COMPOSE_OPTIONS) !== undefined && {
21
23
  authOptions: buildComposeAuthOptions(AUTH_COMPOSE_OPTIONS),
22
24
  }),
@@ -33,6 +33,7 @@
33
33
  "@cosmicdrift/kumiko-locale-it": "workspace:*"
34
34
  },
35
35
  "devDependencies": {
36
- "@cosmicdrift/kumiko-testing": "workspace:*"
36
+ "@cosmicdrift/kumiko-testing": "workspace:*",
37
+ "fflate": "^0.8.2"
37
38
  }
38
39
  }
@@ -13,6 +13,7 @@
13
13
  import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
14
14
  import type { RunDevAppOptions } from "@cosmicdrift/kumiko-dev-server";
15
15
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { BUNDLED_LISTS } from "../bundled-lists";
16
17
  import { AUTH_COMPOSE_OPTIONS, BOOT_FEATURES, VALIDATE_BOOT_OPTIONS } from "../run-config";
17
18
  import { ADMIN_EMAIL, ADMIN_PASSWORD, BETA_TENANT_ID, DEV_TENANT_ID } from "./auth-constants";
18
19
  import { invoiceReportsFeature } from "./invoice-reports-feature";
@@ -31,6 +32,7 @@ export const devAppOptions = {
31
32
  features: [...BOOT_FEATURES, appScreensFeature, notesFeature, invoiceReportsFeature],
32
33
  featureToggles: true,
33
34
  validateBootOptions: VALIDATE_BOOT_OPTIONS,
35
+ bundledLists: BUNDLED_LISTS,
34
36
  port: Number.parseInt(process.env["PORT"] ?? "4186", 10),
35
37
  clientEntry: "./src/app/client.tsx",
36
38
  htmlPath: "./public/index.html",
@@ -45,6 +45,7 @@ export const noteEntity = createEntity({
45
45
  required: true,
46
46
  maxLength: 200,
47
47
  sortable: true,
48
+ searchable: true,
48
49
  }),
49
50
  customFields: customFieldsField(),
50
51
  },
@@ -65,6 +66,7 @@ const noteListScreen: EntityListScreenDefinition = {
65
66
  },
66
67
  ],
67
68
  searchable: true,
69
+ table: { export: { xlsx: true } },
68
70
  defaultSort: { field: "title", dir: "asc" },
69
71
  slots: { header: { react: { __component: TAGS_FILTER_EXTENSION_NAME } } },
70
72
  rowActions: [
@@ -0,0 +1,15 @@
1
+ import type { BundledListsOptions } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export const BUNDLED_LISTS = {
4
+ table: {
5
+ rememberState: true,
6
+ resetAll: true,
7
+ columnPicker: true,
8
+ density: { userChoice: true },
9
+ pinning: true,
10
+ resizing: true,
11
+ keyboardNavigation: true,
12
+ export: { xlsx: true },
13
+ },
14
+ pinFirstColumn: true,
15
+ } as const satisfies BundledListsOptions;
@@ -7,7 +7,7 @@ import {
7
7
  emitEvent,
8
8
  setFields,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
- import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
11
11
  import { z } from "zod";
12
12
 
13
13
  export const ticketEntity = createEntity({
@@ -43,6 +43,7 @@ export const helpdeskFeature = defineFeature("helpdesk", (r) => {
43
43
  if (!ticket) {
44
44
  return failNotFound("ticket", event.payload.id);
45
45
  }
46
+ if (ticket["isClosed"]) return failUnprocessable("ticket_already_closed");
46
47
  await emitEvent(ctx, ticketClosed, {
47
48
  aggregateId: event.payload.id,
48
49
  aggregateType: "ticket",
@@ -27,6 +27,10 @@ back.
27
27
  - **`r.hook({ type: "postDelete", target: { allOf: entityRef }, handler })`** — fires after commit.
28
28
  External side effects (logs, notifications) belong here because
29
29
  failures cannot roll the delete back.
30
+ - **`ctx.scheduleAfterRollback(fn)`** — the counterpart of `scheduleAfterCommit`: runs `fn` when
31
+ the handler transaction (or the enclosing savepoint of a nested `ctx.write`) does not commit.
32
+ Use it to give back something reserved outside the transaction, like the slot in
33
+ `article:reserve-slot`. A failed COMMIT triggers it too, so write the callback idempotent.
30
34
 
31
35
  ## When to reach for it
32
36
 
@@ -13,7 +13,12 @@ import {
13
13
  defineFeature,
14
14
  type SaveContext,
15
15
  } from "@cosmicdrift/kumiko-framework/engine";
16
- import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
16
+ import {
17
+ ConflictError,
18
+ UnprocessableError,
19
+ writeFailure,
20
+ } from "@cosmicdrift/kumiko-framework/errors";
21
+ import * as z from "zod";
17
22
 
18
23
  export const articleEntity = createEntity({
19
24
  table: "read_sample_articles",
@@ -35,6 +40,9 @@ export const articleEntity = createEntity({
35
40
  // Collect hook events for testing
36
41
  export const hookLog: { type: string; data: Record<string, unknown> }[] = [];
37
42
 
43
+ // Stands in for a quota held in an external system, outside the handler transaction.
44
+ export const reservedSlots = new Set<string>();
45
+
38
46
  const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
39
47
 
40
48
  export const articlesFeature = defineFeature("blog", (r) => {
@@ -48,6 +56,23 @@ export const articlesFeature = defineFeature("blog", (r) => {
48
56
  );
49
57
  r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
50
58
 
59
+ // --- scheduleAfterRollback: give back a reservation made outside the transaction when it does
60
+ // not commit. The callback must be idempotent: a failed COMMIT also triggers it.
61
+ r.writeHandler({
62
+ name: "article:reserve-slot",
63
+ schema: z.object({ slot: z.string(), reject: z.boolean() }),
64
+ access: editorWrite.access,
65
+ handler: async (event, ctx) => {
66
+ const { slot, reject } = event.payload;
67
+ reservedSlots.add(slot);
68
+ ctx.scheduleAfterRollback?.(async () => {
69
+ reservedSlots.delete(slot);
70
+ });
71
+ if (reject) return writeFailure(new UnprocessableError("slot_rejected"));
72
+ return { isSuccess: true as const, data: {} };
73
+ },
74
+ });
75
+
51
76
  // --- Validation hook on create: reject banned words + length ---
52
77
  r.hook({
53
78
  type: "validation",
@@ -0,0 +1,50 @@
1
+ # List table options
2
+
3
+ Opt-in table behaviour for entity, projection and related lists. A list without
4
+ the `table` block renders and behaves as before.
5
+
6
+ ## What it shows
7
+
8
+ - `table.rememberState`: search, sort, page size, facets and date range survive
9
+ leaving and re-entering the list within the browser session.
10
+ - `table.resetAll`: one button resets everything that differs from the defaults.
11
+ - `table.columnPicker`: the view menu lets the user hide and reorder columns;
12
+ `hidden: true` on a column starts it hidden.
13
+ - `table.density`: row density default and an optional user choice.
14
+ - `table.pinning`: the user pins columns left or right in the view menu;
15
+ `pinned: "left"` on a column starts it pinned.
16
+ - `table.resizing`: columns can be resized (table-layout fixed); `width` and
17
+ `minWidth` on a column are start values in px.
18
+ - `table.keyboardNavigation`: rows are one tab stop, arrow keys move between rows,
19
+ Space selects the focused row.
20
+ - `table.export`: an Export button downloads the whole filtered list as an XLSX
21
+ file; `maxRows` lowers the default limit of 10000 rows.
22
+ Reference columns export their label and timestamps use the browser time zone.
23
+ - `bulkActions`: row checkboxes and a selection bar. "Delete" runs the delete
24
+ row action once per selected row; done tasks are skipped.
25
+ - `table.grouping`: the server groups the list by status; each header shows the
26
+ group's count and estimate sum, "done" starts collapsed and the view menu
27
+ offers grouping by priority or none.
28
+ - `table.totals`: a footer row with the estimate sum over the filtered list.
29
+ - `facets.estimateHours: { display: "range" }`: from/to inputs for a number field.
30
+ - The same block on a `projectionList`, and `task:workload`, a hand-written list
31
+ query that keeps grouping and totals by accepting `aggregate`
32
+ (`entityListSchema`) and answering it with `answerListAggregate`.
33
+
34
+ ## Source
35
+
36
+ Feature entry point: `src/feature.ts`.
37
+
38
+ ## Tests
39
+
40
+ ```bash
41
+ cd samples/recipes/list-table-options
42
+ bun test
43
+ ```
44
+
45
+ The test boots the feature through the boot validator and checks that a
46
+ misspelled key, a hidden column without the picker, an unknown density, a pinned column
47
+ without `pinning`, a width without `resizing` and a bulk action on a navigate
48
+ row action are rejected.
49
+ `workload.integration.test.ts` runs the hand-written query over HTTP and checks
50
+ the grouped sums and the filtered total.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-sample-list-table-options",
3
+ "description": "Opt-in table behaviour of a list: remembered search/sort/filters, one reset button, column picker with hidden columns, row density, server-side groups with sums, a totals row and a number range facet.",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@cosmicdrift/kumiko-framework": "workspace:*"
7
+ },
8
+ "devDependencies": {
9
+ "@cosmicdrift/kumiko-testing": "workspace:*"
10
+ }
11
+ }
@@ -0,0 +1,221 @@
1
+ import {
2
+ answerListAggregate,
3
+ buildEntityTable,
4
+ createEventStoreExecutor,
5
+ } from "@cosmicdrift/kumiko-framework/db";
6
+ import {
7
+ createDateField,
8
+ createEntity,
9
+ createNumberField,
10
+ createSelectField,
11
+ createTextField,
12
+ defineFeature,
13
+ entityListSchema,
14
+ type FeatureDefinition,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import type { ListTableOptions } from "@cosmicdrift/kumiko-framework/ui-types";
17
+ import { z } from "zod";
18
+
19
+ export const taskEntity = createEntity({
20
+ table: "read_list_table_tasks",
21
+ fields: {
22
+ title: createTextField({
23
+ personal: false,
24
+ reason: "is_business_data",
25
+ required: true,
26
+ searchable: true,
27
+ sortable: true,
28
+ }),
29
+ status: createSelectField({
30
+ options: ["open", "inProgress", "blocked", "done"] as const,
31
+ default: "open",
32
+ sortable: true,
33
+ filterable: true,
34
+ }),
35
+ priority: createSelectField({
36
+ options: ["low", "medium", "high"] as const,
37
+ default: "medium",
38
+ sortable: true,
39
+ filterable: true,
40
+ }),
41
+ dueDate: createDateField({ sortable: true }),
42
+ assignee: createTextField({ personal: false, reason: "is_business_data", sortable: true }),
43
+ estimateHours: createNumberField({ sortable: true, filterable: true }),
44
+ },
45
+ });
46
+
47
+ export const openAccess = {
48
+ access: {
49
+ openToAll: { reason: "recipe: any signed-in user manages the tasks; no per-user ownership" },
50
+ },
51
+ } as const;
52
+
53
+ // Overridable so a test can feed the boot validator a block that must be rejected.
54
+ export function createTaskBoardFeature(
55
+ taskTable: ListTableOptions = {
56
+ rememberState: true,
57
+ resetAll: true,
58
+ columnPicker: true,
59
+ density: { default: "comfortable", userChoice: true },
60
+ pinning: true,
61
+ resizing: true,
62
+ keyboardNavigation: true,
63
+ export: { xlsx: true, maxRows: 5000 },
64
+ grouping: { field: "status", collapsedWhen: "done", userChoice: true, fields: ["priority"] },
65
+ totals: [{ field: "estimateHours", kind: "sum" }],
66
+ },
67
+ ): FeatureDefinition {
68
+ return defineFeature("task-board", (r) => {
69
+ r.crud("task", taskEntity, { write: openAccess, read: openAccess });
70
+
71
+ r.screen({
72
+ id: "task-list",
73
+ type: "entityList",
74
+ entity: "task",
75
+ columns: [
76
+ // Starts pinned left; width and minWidth are start values for resizing.
77
+ { field: "title", pinned: "left", width: 280, minWidth: 160 },
78
+ "status",
79
+ "priority",
80
+ { field: "dueDate", hideOnNarrow: true },
81
+ { field: "assignee", hideOnNarrow: true },
82
+ // Starts hidden; the user can add it back in the view menu.
83
+ { field: "estimateHours", hidden: true, hideOnNarrow: true },
84
+ ],
85
+ facets: { status: { display: "chips" }, priority: {}, estimateHours: { display: "range" } },
86
+ defaultSort: { field: "dueDate", dir: "asc" },
87
+ searchable: true,
88
+ table: taskTable,
89
+ rowActions: [
90
+ { kind: "navigate", id: "open", label: "Open", screen: "task-edit", rowClick: true },
91
+ {
92
+ kind: "writeHandler",
93
+ id: "delete",
94
+ label: "Delete",
95
+ handler: "task-board:write:task:delete",
96
+ style: "danger",
97
+ // Done tasks are skipped by the bulk run and counted as skipped.
98
+ visible: { field: "status", ne: "done" },
99
+ },
100
+ ],
101
+ // Row checkboxes and a selection bar; each selected row gets its own delete write.
102
+ bulkActions: [{ id: "delete", rowAction: "delete" }],
103
+ access: openAccess.access,
104
+ });
105
+
106
+ r.screen({
107
+ id: "task-edit",
108
+ type: "entityEdit",
109
+ entity: "task",
110
+ dormant: true,
111
+ listScreenId: "task-list",
112
+ layout: {
113
+ sections: [
114
+ {
115
+ columns: 2,
116
+ fields: ["title", "assignee", "status", "priority", "dueDate", "estimateHours"],
117
+ },
118
+ ],
119
+ },
120
+ });
121
+
122
+ // The same block works on a list over any query.
123
+ r.queryHandler({
124
+ name: "task:overdue",
125
+ schema: z.object({}),
126
+ handler: async () => ({
127
+ rows: [{ id: "t1", title: "Quarterly VAT filing", assignee: "Ines", daysLate: 3 }],
128
+ nextCursor: null,
129
+ }),
130
+ access: openAccess.access,
131
+ outputSchema: z.object({
132
+ rows: z.array(
133
+ z.object({
134
+ id: z.string(),
135
+ title: z.string(),
136
+ assignee: z.string(),
137
+ daysLate: z.number(),
138
+ }),
139
+ ),
140
+ nextCursor: z.string().nullable(),
141
+ }),
142
+ });
143
+
144
+ r.screen({
145
+ id: "task-overdue",
146
+ type: "projectionList",
147
+ query: "task-board:query:task:overdue",
148
+ columns: ["title", "assignee", "daysLate"],
149
+ table: { rememberState: true, density: { userChoice: true } },
150
+ });
151
+
152
+ // A hand-written list query keeps grouping and totals when it accepts
153
+ // `aggregate` (entityListSchema carries it) and answers it on the server.
154
+ const taskExecutor = createEventStoreExecutor(
155
+ buildEntityTable("task", taskEntity),
156
+ taskEntity,
157
+ {
158
+ entityName: "task",
159
+ },
160
+ );
161
+ r.queryHandler({
162
+ name: "task:workload",
163
+ schema: entityListSchema,
164
+ handler: async (query, ctx) => {
165
+ const parentVisibility = { entities: ctx.registry.getAllEntities() };
166
+ const listPayload = query.payload;
167
+ if (listPayload.aggregate !== undefined) {
168
+ return answerListAggregate(
169
+ taskExecutor,
170
+ { ...listPayload, aggregate: listPayload.aggregate },
171
+ query.user,
172
+ ctx.db,
173
+ { parentVisibility },
174
+ {
175
+ entity: taskEntity,
176
+ resolveEntity: (name) => ctx.registry.getEntity(name),
177
+ viewer: { user: query.user, parentVisibility },
178
+ },
179
+ );
180
+ }
181
+ return taskExecutor.list(listPayload, query.user, ctx.db, { parentVisibility });
182
+ },
183
+ access: openAccess.access,
184
+ });
185
+
186
+ r.screen({
187
+ id: "task-workload",
188
+ type: "projectionList",
189
+ query: "task-board:query:task:workload",
190
+ columns: ["title", "assignee", "status", "estimateHours"],
191
+ defaultSort: { field: "title", dir: "asc" },
192
+ table: {
193
+ grouping: { field: "assignee" },
194
+ totals: [{ field: "estimateHours", kind: "sum" }],
195
+ },
196
+ });
197
+
198
+ r.nav({ id: "tasks", label: "task-board:nav.tasks", icon: "list", order: 10 });
199
+ r.nav({
200
+ id: "task-list",
201
+ label: "task-board:nav.list",
202
+ parent: "task-board:nav:tasks",
203
+ screen: "task-board:screen:task-list",
204
+ order: 10,
205
+ });
206
+ r.nav({
207
+ id: "task-overdue",
208
+ label: "task-board:nav.overdue",
209
+ parent: "task-board:nav:tasks",
210
+ screen: "task-board:screen:task-overdue",
211
+ order: 20,
212
+ });
213
+ r.nav({
214
+ id: "task-workload",
215
+ label: "task-board:nav.workload",
216
+ parent: "task-board:nav:tasks",
217
+ screen: "task-board:screen:task-workload",
218
+ order: 30,
219
+ });
220
+ });
221
+ }
@@ -15,6 +15,7 @@ import {
15
15
  createTextField,
16
16
  defineFeature,
17
17
  defineWriteHandler,
18
+ MAX_LIST_LIMIT,
18
19
  } from "@cosmicdrift/kumiko-framework/engine";
19
20
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
20
21
  import {
@@ -51,7 +52,7 @@ function toFileNameSegment(text: string): string {
51
52
  }
52
53
 
53
54
  // One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
54
- const AUDIT_ROW_LIMIT = 100;
55
+ const AUDIT_ROW_LIMIT = MAX_LIST_LIMIT - 1;
55
56
 
56
57
  const access = { access: { roles: ["Admin", "User"] } } as const;
57
58
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { EventDef } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
+ import { INVOICE_STATES } from "./entities/invoice.ts";
8
9
 
9
10
  export const FEATURE_NAME = "billing";
10
11
  export const ENTITY_NAME = "invoice";
@@ -36,8 +37,8 @@ export const INVOICE_CREATED_EVENT = `${ENTITY_NAME}.created` as const;
36
37
 
37
38
  export const emptyEventPayload = z.object({});
38
39
  export const statusForcedPayload = z.object({
39
- newStatus: z.enum(["draft", "sent", "paid", "cancelled"]),
40
- fromStatus: z.enum(["draft", "sent", "paid", "cancelled"]),
40
+ newStatus: z.enum(INVOICE_STATES),
41
+ fromStatus: z.enum(INVOICE_STATES),
41
42
  });
42
43
 
43
44
  // The EventDefs r.defineEvent returns inside the feature setup. Handlers receive them so that
@@ -5,6 +5,7 @@ Multi-tenant data isolation by default.
5
5
  ## What it shows
6
6
 
7
7
  - Tenant filter on ordinary handlers
8
+ - `ctx.db.upsert`: insert-or-update on a tenant-scoped, non-event-sourced table. `conflictTarget` must contain `tenantId` and match a declared unique index (here `tenant_id, key`); event-fed tables are rejected
8
9
  - What breaks if you forget tenancy (and how the framework prevents it)
9
10
 
10
11
  ## Source