@cosmicdrift/kumiko-samples 0.366.0 → 0.368.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 (411) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +16 -2
  3. package/packages/bundled-features/src/audit/handlers/details.query.ts +1 -1
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +1 -1
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +5 -5
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -1
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +10 -3
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +10 -3
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -2
  11. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  12. package/packages/bundled-features/src/auth-email-password/seeding.ts +2 -2
  13. package/packages/bundled-features/src/auth-foundation/feature.ts +43 -34
  14. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  15. package/packages/bundled-features/src/auth-mfa/db/queries.ts +5 -2
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -1
  18. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +2 -2
  19. package/packages/bundled-features/src/auth-mfa/handlers/status.query.ts +1 -1
  20. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -1
  21. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -1
  22. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -1
  23. package/packages/bundled-features/src/auth-mfa-user-data/index.ts +19 -10
  24. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +1 -1
  25. package/packages/bundled-features/src/billing-foundation/feature.ts +138 -135
  26. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +1 -1
  27. package/packages/bundled-features/src/billing-foundation/projection.ts +3 -2
  28. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +52 -1
  29. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +203 -15
  30. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  31. package/packages/bundled-features/src/cap-counter/feature.ts +84 -73
  32. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +3 -4
  33. package/packages/bundled-features/src/cap-counter/index.ts +9 -1
  34. package/packages/bundled-features/src/cap-counter/tables.ts +10 -3
  35. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -2
  36. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +6 -2
  37. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +1 -1
  38. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  39. package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
  40. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  41. package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +1 -1
  42. package/packages/bundled-features/src/channel-in-app/handlers/mark-all-read.write.ts +1 -1
  43. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +1 -1
  44. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +1 -1
  45. package/packages/bundled-features/src/channel-in-app/tables.ts +1 -1
  46. package/packages/bundled-features/src/compliance-profiles/handlers/for-tenant.query.ts +1 -1
  47. package/packages/bundled-features/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
  48. package/packages/bundled-features/src/compliance-profiles/handlers/set-profile.write.ts +1 -2
  49. package/packages/bundled-features/src/compliance-profiles/resolve-for-tenant.ts +1 -1
  50. package/packages/bundled-features/src/compliance-profiles/seeding.ts +8 -4
  51. package/packages/bundled-features/src/compliance-profiles-ops/index.ts +26 -17
  52. package/packages/bundled-features/src/config/db/queries/resolver.ts +1 -1
  53. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +2 -2
  54. package/packages/bundled-features/src/config/resolver.ts +1 -1
  55. package/packages/bundled-features/src/config/write-helpers.ts +1 -1
  56. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  57. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +9 -7
  58. package/packages/bundled-features/src/custom-fields/db/queries/field-access.ts +1 -1
  59. package/packages/bundled-features/src/custom-fields/db/queries/projection.ts +1 -1
  60. package/packages/bundled-features/src/custom-fields/db/queries/quota.ts +1 -1
  61. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +6 -2
  62. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +1 -1
  63. package/packages/bundled-features/src/custom-fields/feature.ts +11 -14
  64. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +2 -2
  65. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +2 -2
  66. package/packages/bundled-features/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -2
  67. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +2 -2
  68. package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +4 -4
  69. package/packages/bundled-features/src/data-retention/resolve-for-tenant.ts +1 -1
  70. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +16 -10
  71. package/packages/bundled-features/src/delivery/address-opt-out.ts +5 -2
  72. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +1 -1
  73. package/packages/bundled-features/src/delivery/changes.json +12 -0
  74. package/packages/bundled-features/src/delivery/channel-context.ts +2 -1
  75. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +1 -1
  76. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +1 -1
  77. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -8
  78. package/packages/bundled-features/src/delivery/feature.ts +2 -2
  79. package/packages/bundled-features/src/delivery/handlers/log.query.ts +3 -2
  80. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +1 -1
  81. package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +10 -5
  82. package/packages/bundled-features/src/delivery/index.ts +6 -0
  83. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  84. package/packages/bundled-features/src/delivery/types.ts +4 -0
  85. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
  86. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
  87. package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
  88. package/packages/bundled-features/src/delivery/upsert-preference.ts +5 -2
  89. package/packages/bundled-features/src/derivatives-sharp/feature.ts +21 -12
  90. package/packages/bundled-features/src/derivatives-sharp/index.ts +1 -1
  91. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +112 -103
  92. package/packages/bundled-features/src/document-ingest-foundation/forget-extract-with-file-ref.ts +3 -3
  93. package/packages/bundled-features/src/document-ingest-foundation/index.ts +4 -1
  94. package/packages/bundled-features/src/feature-toggles/global-feature-state-table.ts +1 -1
  95. package/packages/bundled-features/src/feature-toggles/handlers/set.write.ts +3 -5
  96. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +1 -1
  97. package/packages/bundled-features/src/file-derivatives/feature.ts +134 -129
  98. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  99. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  100. package/packages/bundled-features/src/file-foundation/feature.ts +38 -29
  101. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  102. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +1 -1
  103. package/packages/bundled-features/src/file-provider-inmemory/feature.ts +32 -21
  104. package/packages/bundled-features/src/file-provider-inmemory/index.ts +6 -1
  105. package/packages/bundled-features/src/file-provider-s3/changes.json +10 -1
  106. package/packages/bundled-features/src/file-provider-s3/env-feature.ts +62 -0
  107. package/packages/bundled-features/src/file-provider-s3/feature.ts +107 -73
  108. package/packages/bundled-features/src/file-provider-s3/index.ts +6 -1
  109. package/packages/bundled-features/src/file-provider-s3/lib/index.ts +3 -0
  110. package/packages/bundled-features/src/{files-provider-s3 → file-provider-s3/lib}/s3-provider.ts +19 -19
  111. package/packages/bundled-features/src/file-provider-s3-env/index.ts +7 -2
  112. package/packages/bundled-features/src/files-provider-s3/index.ts +11 -3
  113. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +1 -1
  114. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -2
  115. package/packages/bundled-features/src/folders/feature.ts +8 -3
  116. package/packages/bundled-features/src/folders-user-data/index.ts +27 -18
  117. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +1 -1
  118. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +1 -1
  119. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +3 -2
  120. package/packages/bundled-features/src/form-draft/feature.ts +6 -1
  121. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +2 -2
  122. package/packages/bundled-features/src/form-draft/index.ts +1 -1
  123. package/packages/bundled-features/src/form-draft/lookup.ts +1 -1
  124. package/packages/bundled-features/src/form-draft-user-data/index.ts +19 -10
  125. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +165 -154
  126. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -2
  127. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +1 -1
  128. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +5 -2
  129. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +3 -2
  130. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
  131. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +22 -13
  132. package/packages/bundled-features/src/inbound-provider-imap/index.ts +6 -1
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +20 -12
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +1 -0
  135. package/packages/bundled-features/src/jobs/db/queries/retention.ts +1 -1
  136. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +1 -1
  137. package/packages/bundled-features/src/jobs/handlers/detail.query.ts +1 -1
  138. package/packages/bundled-features/src/jobs/handlers/list.query.ts +7 -2
  139. package/packages/bundled-features/src/jobs/handlers/retry.write.ts +1 -1
  140. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  141. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -1
  142. package/packages/bundled-features/src/jobs/job-run-logger.ts +11 -10
  143. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +1 -1
  144. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +1 -1
  145. package/packages/bundled-features/src/ledger/feature.ts +6 -3
  146. package/packages/bundled-features/src/mail-foundation/feature.ts +107 -55
  147. package/packages/bundled-features/src/mail-foundation/index.ts +4 -0
  148. package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +25 -17
  149. package/packages/bundled-features/src/mail-transport-inmemory/index.ts +6 -1
  150. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +91 -79
  151. package/packages/bundled-features/src/mail-transport-smtp/index.ts +1 -0
  152. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +1 -1
  153. package/packages/bundled-features/src/managed-pages/handlers/by-tenant-published.query.ts +1 -2
  154. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -2
  155. package/packages/bundled-features/src/managed-pages/seeding.ts +8 -4
  156. package/packages/bundled-features/src/metrics/compute.ts +1 -1
  157. package/packages/bundled-features/src/metrics/filters.ts +1 -1
  158. package/packages/bundled-features/src/metrics/validate.ts +1 -1
  159. package/packages/bundled-features/src/notes-history/feature.ts +6 -3
  160. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  161. package/packages/bundled-features/src/notes-history-user-data/hooks.ts +1 -1
  162. package/packages/bundled-features/src/notes-history-user-data/index.ts +27 -18
  163. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  164. package/packages/bundled-features/src/personal-access-tokens/feature.ts +17 -2
  165. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -1
  166. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -2
  167. package/packages/bundled-features/src/personal-access-tokens/handlers/revoke.write.ts +1 -1
  168. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -1
  169. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -2
  170. package/packages/bundled-features/src/personal-access-tokens/roles.ts +8 -6
  171. package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
  172. package/packages/bundled-features/src/readiness/feature.ts +25 -16
  173. package/packages/bundled-features/src/readiness/index.ts +1 -1
  174. package/packages/bundled-features/src/secrets/db/queries/read.ts +1 -1
  175. package/packages/bundled-features/src/secrets/feature.ts +8 -3
  176. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +2 -2
  177. package/packages/bundled-features/src/secrets/secrets-context.ts +4 -3
  178. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +1 -1
  179. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +1 -1
  180. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -2
  181. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +1 -1
  182. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  183. package/packages/bundled-features/src/sessions/handlers/revoke-all-others.write.ts +1 -1
  184. package/packages/bundled-features/src/sessions/handlers/revoke.write.ts +1 -1
  185. package/packages/bundled-features/src/sessions/session-callbacks.ts +14 -7
  186. package/packages/bundled-features/src/sessions/testing.ts +1 -1
  187. package/packages/bundled-features/src/tags/feature.ts +6 -3
  188. package/packages/bundled-features/src/template-resolver/api.ts +1 -1
  189. package/packages/bundled-features/src/template-resolver/changes.json +25 -0
  190. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
  191. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
  192. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
  193. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +10 -2
  194. package/packages/bundled-features/src/template-resolver/handlers/by-slug.query.ts +1 -1
  195. package/packages/bundled-features/src/template-resolver/handlers/by-tenant.query.ts +1 -2
  196. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  197. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -2
  198. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +1 -1
  199. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +9 -3
  200. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -1
  201. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +1 -1
  202. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +9 -2
  203. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +1 -1
  204. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +7 -2
  205. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +8 -3
  206. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +1 -1
  207. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +1 -1
  208. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -1
  209. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -2
  210. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +1 -1
  211. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +1 -1
  212. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +1 -1
  213. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +1 -1
  214. package/packages/bundled-features/src/template-resolver/index.ts +4 -0
  215. package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
  216. package/packages/bundled-features/src/template-resolver/seeding.ts +57 -10
  217. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +1 -1
  218. package/packages/bundled-features/src/template-resolver/template-preview.ts +3 -1
  219. package/packages/bundled-features/src/template-resolver/template-resources.ts +1 -1
  220. package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
  221. package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
  222. package/packages/bundled-features/src/template-resolver/testing.ts +1 -1
  223. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
  224. package/packages/bundled-features/src/template-resolver-user-data/index.ts +23 -14
  225. package/packages/bundled-features/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  226. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +1 -2
  227. package/packages/bundled-features/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  228. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +1 -1
  229. package/packages/bundled-features/src/tenant/handlers/me.query.ts +1 -1
  230. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +1 -1
  231. package/packages/bundled-features/src/tenant/handlers/members.query.ts +1 -1
  232. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +1 -1
  233. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +2 -1
  234. package/packages/bundled-features/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  235. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +1 -1
  236. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +1 -1
  237. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -2
  238. package/packages/bundled-features/src/tenant/invited-membership.ts +2 -2
  239. package/packages/bundled-features/src/tenant/last-tenant-admin.ts +1 -2
  240. package/packages/bundled-features/src/tenant/seeding.ts +12 -12
  241. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -2
  242. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -2
  243. package/packages/bundled-features/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +1 -1
  244. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +1 -1
  245. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +3 -3
  246. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +10 -5
  247. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +3 -3
  248. package/packages/bundled-features/src/tenant-settings/feature.ts +34 -29
  249. package/packages/bundled-features/src/tier-engine/feature.ts +426 -428
  250. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +2 -3
  251. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +5 -2
  252. package/packages/bundled-features/src/user/changes.json +6 -0
  253. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +1 -1
  254. package/packages/bundled-features/src/user/handlers/create.write.ts +1 -2
  255. package/packages/bundled-features/src/user/handlers/find-for-auth.query.ts +1 -1
  256. package/packages/bundled-features/src/user/handlers/list.query.ts +1 -1
  257. package/packages/bundled-features/src/user/handlers/update-roles.ts +2 -1
  258. package/packages/bundled-features/src/user/index.ts +5 -1
  259. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -2
  260. package/packages/bundled-features/src/user/principal-status.ts +13 -3
  261. package/packages/bundled-features/src/user/resolve-display-names.ts +1 -1
  262. package/packages/bundled-features/src/user/seeding.ts +5 -5
  263. package/packages/bundled-features/src/user-data-rights/audit-download.ts +2 -2
  264. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  265. package/packages/bundled-features/src/user-data-rights/db/queries/export-jobs.ts +1 -1
  266. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +1 -1
  267. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  268. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  269. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  270. package/packages/bundled-features/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -1
  271. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  272. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -2
  273. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -3
  274. package/packages/bundled-features/src/user-data-rights/lib/deny-if-target-outside-admin-tenant.ts +1 -1
  275. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +7 -2
  276. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +7 -3
  277. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +11 -5
  278. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +10 -5
  279. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +1 -2
  280. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +1 -1
  281. package/packages/bundled-features/src/waitlist/lib.ts +5 -2
  282. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +1 -1
  283. package/packages/bundled-features/src/workflow-runner/feature.ts +58 -47
  284. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  285. package/packages/bundled-features/src/workflow-runner/index.ts +1 -1
  286. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -1
  287. package/samples/apps/admin-console/src/run-config.ts +2 -2
  288. package/samples/apps/cap-billing-demo/package.json +3 -0
  289. package/samples/apps/cap-billing-demo/src/feature.ts +2 -2
  290. package/samples/apps/cap-billing-demo/src/run-config.ts +8 -8
  291. package/samples/apps/marketing-demo/src/app/seed.ts +1 -1
  292. package/samples/apps/marketing-demo/src/db/queries/seed-counts.ts +1 -1
  293. package/samples/apps/showcase/package.json +3 -0
  294. package/samples/apps/showcase/src/app/seed-items.ts +1 -1
  295. package/samples/apps/styleguide/src/app/seed.ts +2 -2
  296. package/samples/apps/styleguide/src/features/ai-settings/seed.ts +2 -2
  297. package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +1 -1
  298. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +1 -1
  299. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +2 -2
  300. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  301. package/samples/apps/ui-walkthrough/src/app/seed-vehicles.ts +1 -1
  302. package/samples/apps/ui-walkthrough/src/app/seed.ts +1 -1
  303. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
  304. package/samples/apps/ui-walkthrough/src/db/queries/row-counts.ts +1 -1
  305. package/samples/apps/ui-walkthrough/src/db/queries/task-counts.ts +1 -1
  306. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
  307. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
  308. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  309. package/samples/apps/use-all-bundled/package.json +4 -0
  310. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +2 -2
  311. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +1 -1
  312. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +2 -2
  313. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +1 -1
  314. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +1 -1
  315. package/samples/apps/use-all-bundled/src/app/seed.ts +11 -5
  316. package/samples/apps/use-all-bundled/src/run-config.ts +55 -56
  317. package/samples/apps/user-data-rights-demo/README.md +2 -2
  318. package/samples/apps/user-data-rights-demo/package.json +3 -0
  319. package/samples/apps/user-data-rights-demo/src/run-config.ts +7 -7
  320. package/samples/apps/workspaces/package.json +3 -0
  321. package/samples/recipes/access-control/package.json +3 -0
  322. package/samples/recipes/anonymous-access/package.json +3 -0
  323. package/samples/recipes/anonymous-access-multitenant/package.json +3 -0
  324. package/samples/recipes/apex-landing/package.json +1 -0
  325. package/samples/recipes/apex-surface-auth/package.json +3 -0
  326. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  327. package/samples/recipes/api-worker-split/package.json +3 -0
  328. package/samples/recipes/api-worker-split/src/feature.ts +1 -2
  329. package/samples/recipes/auth-claims/package.json +3 -0
  330. package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
  331. package/samples/recipes/auth-mfa/package.json +3 -0
  332. package/samples/recipes/basic-entity/package.json +3 -0
  333. package/samples/recipes/chat-channels/package.json +3 -0
  334. package/samples/recipes/compliance-profiles-demo/package.json +3 -0
  335. package/samples/recipes/cross-feature-events/package.json +3 -1
  336. package/samples/recipes/cross-feature-events/src/feature.ts +1 -1
  337. package/samples/recipes/crypto-shredding-hr/package.json +3 -0
  338. package/samples/recipes/currencies-global/package.json +3 -0
  339. package/samples/recipes/currencies-per-tenant/package.json +3 -0
  340. package/samples/recipes/custom-fields-basic/package.json +3 -0
  341. package/samples/recipes/custom-handlers/package.json +3 -0
  342. package/samples/recipes/dashboard-stats/package.json +3 -0
  343. package/samples/recipes/delivery-notifications/package.json +3 -0
  344. package/samples/recipes/e2e-generator/package.json +3 -0
  345. package/samples/recipes/embedded/package.json +3 -0
  346. package/samples/recipes/embedded-entity-form/package.json +1 -0
  347. package/samples/recipes/encrypted-tenant-config/README.md +1 -1
  348. package/samples/recipes/encrypted-tenant-config/package.json +3 -0
  349. package/samples/recipes/error-contract/package.json +3 -0
  350. package/samples/recipes/event-sourcing/package.json +3 -1
  351. package/samples/recipes/event-sourcing/src/feature.ts +16 -20
  352. package/samples/recipes/event-sourcing/tsconfig.json +2 -6
  353. package/samples/recipes/feature-toggles/package.json +3 -0
  354. package/samples/recipes/field-access/package.json +3 -0
  355. package/samples/recipes/files-post-processing/package.json +1 -0
  356. package/samples/recipes/golden-path/README.md +30 -0
  357. package/samples/recipes/golden-path/package.json +11 -0
  358. package/samples/recipes/golden-path/src/feature.ts +70 -0
  359. package/samples/recipes/idempotency/package.json +3 -0
  360. package/samples/recipes/inbound-mail-triage/package.json +3 -0
  361. package/samples/recipes/lane-routing/package.json +3 -0
  362. package/samples/recipes/ledger-banking/package.json +3 -0
  363. package/samples/recipes/ledger-invoicing/package.json +3 -0
  364. package/samples/recipes/legal-pages/package.json +3 -0
  365. package/samples/recipes/lifecycle-hooks/package.json +3 -0
  366. package/samples/recipes/managed-config/package.json +3 -0
  367. package/samples/recipes/managed-pages/package.json +3 -0
  368. package/samples/recipes/notes-history-basic/package.json +3 -0
  369. package/samples/recipes/ownership/package.json +3 -0
  370. package/samples/recipes/pdf-audit-export/package.json +1 -0
  371. package/samples/recipes/pipeline-basics/package.json +3 -0
  372. package/samples/recipes/public-share-token/package.json +3 -0
  373. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  374. package/samples/recipes/rate-limiting/package.json +3 -0
  375. package/samples/recipes/realtime-sse/package.json +3 -0
  376. package/samples/recipes/record-detail-layout/package.json +1 -0
  377. package/samples/recipes/reference-data/package.json +3 -0
  378. package/samples/recipes/relations/package.json +3 -0
  379. package/samples/recipes/screens-nav/package.json +3 -0
  380. package/samples/recipes/search/package.json +3 -0
  381. package/samples/recipes/secrets-demo/package.json +3 -0
  382. package/samples/recipes/security-baseline/src/feature.ts +2 -2
  383. package/samples/recipes/session-revocation/README.md +1 -1
  384. package/samples/recipes/session-revocation/package.json +3 -0
  385. package/samples/recipes/session-revocation/src/feature.ts +1 -1
  386. package/samples/recipes/state-machine/package.json +3 -1
  387. package/samples/recipes/state-machine/src/entities/invoice.ts +2 -2
  388. package/samples/recipes/state-machine/src/events.ts +19 -0
  389. package/samples/recipes/state-machine/src/feature.ts +25 -30
  390. package/samples/recipes/state-machine/src/handlers/_helpers.ts +13 -14
  391. package/samples/recipes/state-machine/src/handlers/create.ts +1 -1
  392. package/samples/recipes/state-machine/src/handlers/transitions.ts +129 -122
  393. package/samples/recipes/state-machine/src/reducer.ts +1 -1
  394. package/samples/recipes/state-machine/tsconfig.json +2 -6
  395. package/samples/recipes/tags-basic/package.json +3 -0
  396. package/samples/recipes/tenant-isolation/package.json +3 -0
  397. package/samples/recipes/tenant-settings/package.json +3 -0
  398. package/samples/recipes/tier-admin/package.json +3 -0
  399. package/samples/recipes/timezones/package.json +3 -0
  400. package/samples/recipes/tsconfig.base.json +1 -1
  401. package/samples/recipes/user-data-rights/package.json +3 -0
  402. package/samples/recipes/user-data-rights/src/feature.ts +6 -2
  403. package/samples/recipes/user-profile/package.json +3 -0
  404. package/samples/recipes/user-profile/src/feature.ts +2 -2
  405. package/samples/recipes/webhook-step/package.json +3 -0
  406. package/samples/recipes/wizard-form/package.json +1 -0
  407. package/samples/recipes/workflow-engine/package.json +3 -0
  408. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +5 -2
  409. package/samples/recipes/writeform-section/package.json +1 -0
  410. package/packages/bundled-features/src/file-provider-s3-env/feature.ts +0 -58
  411. /package/packages/bundled-features/src/{files-provider-s3 → file-provider-s3/lib}/env-helper.ts +0 -0
@@ -41,11 +41,12 @@
41
41
  // **Boot-Dependencies:** config + tenant.
42
42
 
43
43
  // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312 (feature.ts > 300 lines)
44
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
45
44
  import {
46
45
  buildEntityTable,
47
46
  createEventStoreExecutor,
48
47
  createTenantDb,
48
+ fetchOne,
49
+ selectMany,
49
50
  } from "@cosmicdrift/kumiko-framework/db";
50
51
  import {
51
52
  defineEntityCreateHandler,
@@ -197,454 +198,451 @@ function mergeAlwaysOn(
197
198
  export function createTierEngineFeature<
198
199
  TCaps extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
199
200
  >(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
200
- return defineFeature(TIER_ENGINE_FEATURE, (r) => {
201
- r.describe(
202
- 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen, which is always registered — in storage-only mode (no `tierMap`) it shows an honest "no tiers configured" message instead of a tier dropdown.',
203
- );
204
- r.uiHints({
205
- displayLabel: "Tier Engine \u00b7 Plan Composition",
206
- category: "billing",
207
- recommended: false,
208
- });
209
- r.requires({ features: ["config"] });
210
- r.requires({ features: ["tenant"] });
211
-
212
- r.entity({ name: "tier-assignment", ...tierAssignmentEntity });
213
-
214
- // Standard-CRUD via Helper.
215
- r.writeHandler(
216
- defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, {
217
- ...writeAccess,
218
- // Bypasses the feature-gate refresh that set-tenant-tier performs.
219
- agent: { expose: false },
220
- }),
221
- );
222
- r.writeHandler(
223
- defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, {
224
- ...writeAccess,
225
- agent: { expose: false },
226
- }),
227
- );
228
-
229
- // Reads.
230
- r.queryHandler(
231
- defineEntityListHandler("tier-assignment", tierAssignmentEntity, {
232
- ...adminAccess,
233
- description:
234
- "Lists the tier-assignment rows visible to the caller with tier name and assignment source; use it to see which plan is stored for a tenant.",
235
- }),
236
- );
237
- r.queryHandler(getActiveTierQuery);
238
-
239
- // \u2500\u2500 Manueller Tier-Grant (SystemAdmin, ohne Billing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
240
- // Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
241
- // dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
242
- //
243
- // onAssigned runs after commit (the direct executor write fires no postSave
244
- // hook). Late-bound: without tierMap it stays a no-op, the tierMap block
245
- // below installs the real cache update + publish.
246
- const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
247
- r.writeHandler(
248
- createSetTenantTierWrite({
249
- onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
250
- validTiers: opts.tierMap ? new Set(Object.keys(opts.tierMap)) : undefined,
251
- }),
252
- );
253
- r.queryHandler(getTenantTierQuery);
254
- r.queryHandler(
255
- defineQueryHandler({
256
- name: "tier-options",
257
- description:
258
- "Returns the tier names the application defines in its tier map; use it to find out which values `set-tenant-tier` accepts.",
259
- schema: z.object({}),
260
- access: { roles: ["SystemAdmin"] },
261
- handler: async () => ({ tiers: opts.tierMap ? Object.keys(opts.tierMap) : [] }),
262
- }),
263
- );
264
-
265
- // Declarative form for the manual grant. SystemAdmin-only, hard-wired
266
- // (platform-admin authority, not app-configurable). Apps place it only
267
- // via r.nav("tier-engine:screen:tier-admin"); tier options are built
268
- // statically from the tierMap closure. Always registered — even in
269
- // storage-only mode (no tierMap) — so the screen shows an honest
270
- // "no tiers configured" message instead of vanishing (samples like
271
- // use-all-bundled deliberately mount storage-only to screenshot that
272
- // empty state).
273
- r.translations({ keys: TIER_ENGINE_I18N });
274
- r.screen({
275
- id: TIER_ADMIN_SCREEN_ID,
276
- type: "actionForm",
277
- handler: TierEngineHandlers.setTenantTier,
278
- fields: {
279
- tenantId: {
280
- type: "reference",
281
- entity: "tenant:tenant",
282
- labelField: "name",
283
- required: true,
201
+ return defineFeature(
202
+ TIER_ENGINE_FEATURE,
203
+ (r) => {
204
+ r.describe(
205
+ 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen, which is always registered — in storage-only mode (no `tierMap`) it shows an honest "no tiers configured" message instead of a tier dropdown.',
206
+ );
207
+ r.uiHints({
208
+ displayLabel: "Tier Engine \u00b7 Plan Composition",
209
+ category: "billing",
210
+ recommended: false,
211
+ });
212
+ r.requires({ features: ["config"] });
213
+ r.requires({ features: ["tenant"] });
214
+
215
+ r.entity({ name: "tier-assignment", ...tierAssignmentEntity });
216
+
217
+ // Standard-CRUD via Helper.
218
+ r.writeHandler(
219
+ defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, {
220
+ ...writeAccess,
221
+ // Bypasses the feature-gate refresh that set-tenant-tier performs.
222
+ agent: { expose: false },
223
+ }),
224
+ );
225
+ r.writeHandler(
226
+ defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, {
227
+ ...writeAccess,
228
+ agent: { expose: false },
229
+ }),
230
+ );
231
+
232
+ // Reads.
233
+ r.queryHandler(
234
+ defineEntityListHandler("tier-assignment", tierAssignmentEntity, {
235
+ ...adminAccess,
236
+ description:
237
+ "Lists the tier-assignment rows visible to the caller with tier name and assignment source; use it to see which plan is stored for a tenant.",
238
+ }),
239
+ );
240
+ r.queryHandler(getActiveTierQuery);
241
+
242
+ // \u2500\u2500 Manueller Tier-Grant (SystemAdmin, ohne Billing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
243
+ // Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
244
+ // dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
245
+ //
246
+ // onAssigned runs after commit (the direct executor write fires no postSave
247
+ // hook). Late-bound: without tierMap it stays a no-op, the tierMap block
248
+ // below installs the real cache update + publish.
249
+ const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
250
+ r.writeHandler(
251
+ createSetTenantTierWrite({
252
+ onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
253
+ validTiers: opts.tierMap ? new Set(Object.keys(opts.tierMap)) : undefined,
254
+ }),
255
+ );
256
+ r.queryHandler(getTenantTierQuery);
257
+ r.queryHandler(
258
+ defineQueryHandler({
259
+ name: "tier-options",
260
+ description:
261
+ "Returns the tier names the application defines in its tier map; use it to find out which values `set-tenant-tier` accepts.",
262
+ schema: z.object({}),
263
+ access: { roles: ["SystemAdmin"] },
264
+ handler: async () => ({ tiers: opts.tierMap ? Object.keys(opts.tierMap) : [] }),
265
+ }),
266
+ );
267
+
268
+ // Declarative form for the manual grant. SystemAdmin-only, hard-wired
269
+ // (platform-admin authority, not app-configurable). Apps place it only
270
+ // via r.nav("tier-engine:screen:tier-admin"); tier options are built
271
+ // statically from the tierMap closure. Always registered — even in
272
+ // storage-only mode (no tierMap) — so the screen shows an honest
273
+ // "no tiers configured" message instead of vanishing (samples like
274
+ // use-all-bundled deliberately mount storage-only to screenshot that
275
+ // empty state).
276
+ r.translations({ keys: TIER_ENGINE_I18N });
277
+ r.screen({
278
+ id: TIER_ADMIN_SCREEN_ID,
279
+ type: "actionForm",
280
+ handler: TierEngineHandlers.setTenantTier,
281
+ fields: {
282
+ tenantId: {
283
+ type: "reference",
284
+ entity: "tenant:tenant",
285
+ labelField: "name",
286
+ required: true,
287
+ },
288
+ tier: {
289
+ type: "select",
290
+ options: opts.tierMap ? Object.keys(opts.tierMap) : [],
291
+ required: true,
292
+ },
284
293
  },
285
- tier: {
286
- type: "select",
287
- options: opts.tierMap ? Object.keys(opts.tierMap) : [],
288
- required: true,
294
+ layout: {
295
+ sections: [
296
+ {
297
+ fields: ["tenantId", "tier"],
298
+ description: opts.tierMap ? "tier-admin.explainer" : "tier-admin.error.noTiers",
299
+ },
300
+ ],
289
301
  },
290
- },
291
- layout: {
292
- sections: [
293
- {
294
- fields: ["tenantId", "tier"],
295
- description: opts.tierMap ? "tier-admin.explainer" : "tier-admin.error.noTiers",
296
- },
297
- ],
298
- },
299
- submitLabel: "tier-admin.submit",
300
- successMessage: "tier-admin.success",
301
- cancelTarget: false,
302
- description: "tier-admin.screen.subtitle",
303
- access: { roles: ["SystemAdmin"] },
304
- // Apps place it via r.nav() (see above) — no nav area to resolve in
305
- // isolation.
306
- dormant: true,
307
- });
308
-
309
- // ───────────────────────────────────────────────────────────────────
310
- // Resolver-extension (only when tierMap is configured)
311
- // ───────────────────────────────────────────────────────────────────
312
- // skip: ohne tierMap ist die feature nur Storage (legacy back-compat
313
- // zu `tierEngineFeature`). Resolver-extension + invalidation-hooks
314
- // brauchen die tierMap zum Mapping tier-name → feature-set.
315
- if (!opts.tierMap) return;
316
-
317
- const tierMap = opts.tierMap;
318
-
319
- // Closure-state: cache per-tenant effective Sets. Hooks halten den
320
- // cache aktuell during process-lifetime; build() im extension-pickup
321
- // pre-loaded existing assignments at boot.
322
- const cache = new Map<TenantId, ReadonlySet<string>>();
323
- // alwaysOn-Set wird in build(deps) aus registry.features berechnet (alle
324
- // non-toggleable features). Hooks brauchen Zugriff darauf für
325
- // mergeAlwaysOn-calls — Late-bind via mutable holder, gefüllt vor allen
326
- // Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
327
- const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
328
- // Bus handed to build(); hooks fire outside build and publish through it.
329
- const busHolder: { bus: CacheSyncBus | undefined } = { bus: undefined };
330
- // Out-of-order guard: every cache mutation or reload start takes a ticket; a
331
- // reload result is applied only while its ticket is still the tenant's latest.
332
- let ticketCounter = 0;
333
- const latestTicket = new Map<TenantId, number>();
334
- const takeTicket = (tenantId: TenantId): number => {
335
- ticketCounter += 1;
336
- latestTicket.set(tenantId, ticketCounter);
337
- return ticketCounter;
338
- };
339
- const publishAssignmentChange = (tenantId: TenantId): void => {
340
- const message: TierAssignmentSyncMessage = { tenantId };
341
- // The writer already updated its own cache; only other pods need to re-read.
342
- busHolder.bus?.publish(CACHE_SYNC_TOPICS.tierAssignment, message, { remoteOnly: true });
343
- };
344
-
345
- // Trial: zeit-abgeleitet aus tenant.inserted_at (≈ Signup), live am
346
- // Feature-Gate geprüft — NICHT im Resolver-Cache. Der Resolver ist
347
- // boot-cached + synchron und sieht weder frische Signups noch den
348
- // Zeitablauf; der Trial-Status ändert sich aber mit der Zeit und gilt ab
349
- // Sekunde 1 nach Signup. Darum lebt er als async trialGate (unten, am
350
- // build-Ende angehängt), den der dispatcher nur auf dem disabled-Pfad
351
- // konsultiert. trialFeatures = fixe Feature-Menge des Trial-Tiers.
352
- if (opts.trial && !tierMap[opts.trial.tier]) {
353
- throw new Error(
354
- `tier-engine: trial.tier "${opts.trial.tier}" is not a key in tierMap — trial would unlock no features`,
355
- );
356
- }
357
- const trialFeatures: ReadonlySet<string> = opts.trial
358
- ? featuresForTier(tierMap, opts.trial.tier)
359
- : new Set();
360
- const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
361
-
362
- // Immediate local visibility on the writing pod; other pods learn via the
363
- // bus and re-read the row.
364
- const applyAssignment = (tenantId: TenantId, tier: string): void => {
365
- takeTicket(tenantId);
366
- cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
367
- publishAssignmentChange(tenantId);
368
- };
369
- onTierAssigned.fn = applyAssignment;
370
-
371
- // Invalidation: tier-assignment CRUD events (e.g. the billing webhook) update the cache.
372
- r.hook({
373
- type: "postSave",
374
- target: { allOf: "tier-assignment" },
375
- handler: async (result) => {
376
- // result.data has tenantId + tier (after entity-update merge)
377
- const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
378
- // skip: defensive guard on the payload shape. Correct entity events
379
- // always carry both fields as strings; a malformed payload (a custom
380
- // handler bug) skips the cache update instead of throwing, because a
381
- // hook error must not block the lifecycle pipeline (afterCommit,
382
- // best-effort side effect).
383
- if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
384
- applyAssignment(data.tenantId as TenantId, data.tier);
385
- },
386
- });
387
- r.hook({
388
- type: "postDelete",
389
- target: { allOf: "tier-assignment" },
390
- handler: async (payload) => {
391
- const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
392
- // skip: gleiche type-guard semantik wie postSave-hook oben.
393
- if (typeof data.tenantId !== "string") return;
394
- const tenantId = data.tenantId as TenantId;
302
+ submitLabel: "tier-admin.submit",
303
+ successMessage: "tier-admin.success",
304
+ cancelTarget: false,
305
+ description: "tier-admin.screen.subtitle",
306
+ access: { roles: ["SystemAdmin"] },
307
+ // Apps place it via r.nav() (see above) — no nav area to resolve in
308
+ // isolation.
309
+ dormant: true,
310
+ });
311
+
312
+ // ───────────────────────────────────────────────────────────────────
313
+ // Resolver-extension (only when tierMap is configured)
314
+ // ───────────────────────────────────────────────────────────────────
315
+ // skip: ohne tierMap ist die feature nur Storage (legacy back-compat
316
+ // zu `tierEngineFeature`). Resolver-extension + invalidation-hooks
317
+ // brauchen die tierMap zum Mapping tier-name → feature-set.
318
+ if (!opts.tierMap) return;
319
+
320
+ const tierMap = opts.tierMap;
321
+
322
+ // Closure state: per-tenant cache of effective sets. Hooks keep the
323
+ // cache current during the process lifetime; build() in the extension
324
+ // pickup pre-loads existing assignments at boot.
325
+ const cache = new Map<TenantId, ReadonlySet<string>>();
326
+ // The alwaysOn set is computed in build(deps) from registry.features (all
327
+ // non-toggleable features). Hooks need it for mergeAlwaysOn calls — late
328
+ // bound via a mutable holder, filled before any request (build runs
329
+ // pre-listen via the runDevApp/runProdApp pickup).
330
+ const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
331
+ // Bus handed to build(); hooks fire outside build and publish through it.
332
+ const busHolder: { bus: CacheSyncBus | undefined } = { bus: undefined };
333
+ // Out-of-order guard: every cache mutation or reload start takes a ticket; a
334
+ // reload result is applied only while its ticket is still the tenant's latest.
335
+ let ticketCounter = 0;
336
+ const latestTicket = new Map<TenantId, number>();
337
+ const takeTicket = (tenantId: TenantId): number => {
338
+ ticketCounter += 1;
339
+ latestTicket.set(tenantId, ticketCounter);
340
+ return ticketCounter;
341
+ };
342
+ const publishAssignmentChange = (tenantId: TenantId): void => {
343
+ const message: TierAssignmentSyncMessage = { tenantId };
344
+ // The writer already updated its own cache; only other pods need to re-read.
345
+ busHolder.bus?.publish(CACHE_SYNC_TOPICS.tierAssignment, message, { remoteOnly: true });
346
+ };
347
+
348
+ // Trial: derived from time (tenant.inserted_at ≈ signup) and checked live
349
+ // at the feature gate — NOT in the resolver cache. The resolver is
350
+ // boot-cached and synchronous, so it sees neither fresh signups nor the
351
+ // passage of time; the trial status changes over time and applies from
352
+ // second 1 after signup. That is why it lives as an async trialGate
353
+ // (below, attached at the end of build) which the dispatcher consults
354
+ // only on the disabled path. trialFeatures = fixed feature set of the
355
+ // trial tier.
356
+ if (opts.trial && !tierMap[opts.trial.tier]) {
357
+ throw new Error(
358
+ `tier-engine: trial.tier "${opts.trial.tier}" is not a key in tierMap — trial would unlock no features`,
359
+ );
360
+ }
361
+ const trialFeatures: ReadonlySet<string> = opts.trial
362
+ ? featuresForTier(tierMap, opts.trial.tier)
363
+ : new Set();
364
+ const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
365
+
366
+ // Immediate local visibility on the writing pod; other pods learn via the
367
+ // bus and re-read the row.
368
+ const applyAssignment = (tenantId: TenantId, tier: string): void => {
395
369
  takeTicket(tenantId);
396
- cache.delete(tenantId);
370
+ cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
397
371
  publishAssignmentChange(tenantId);
398
- },
399
- });
400
-
401
- // Auto-default-tier-on-tenant-signup: hook fires inTransaction (atomic
402
- // mit tenant-create rollback), schreibt tier-assignment via Direct-
403
- // Executor (nicht ctx.write — hook hat AppContext). Pattern analog
404
- // tenant/seeding.ts seedTenant.
405
- //
406
- // **Idempotency:** deterministic aggregate-id aus tenantId. Re-replay
407
- // (nach projection-rebuild oder hook-retry) findet stream-version > 0
408
- // und skipt, statt version_conflict zu werfen. Caller sieht erfolgreichen
409
- // tenant-create + bestehende tier-assignment.
410
- //
411
- // **Cross-tenant write:** executor braucht systemUser MIT tenantId =
412
- // neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
413
- if (opts.defaultTier !== undefined) {
414
- const defaultTier = opts.defaultTier;
415
- const autoDefaultTierHookReason =
416
- "seeds the default tier-assignment stream of the tenant just created";
372
+ };
373
+ onTierAssigned.fn = applyAssignment;
374
+
375
+ // Invalidation: tier-assignment CRUD events (e.g. the billing webhook) update the cache.
417
376
  r.hook({
418
377
  type: "postSave",
419
- target: { allOf: "tenant" },
420
- handler: async (result, ctx) => {
421
- // result-shape: kumiko-framework's SaveContext mit isNew + data
422
- const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
423
- // skip: nur bei tenant-create (initial) — tenant-updates feuern
424
- // auch postSave aber wir wollen kein neues tier-assignment bei
425
- // re-keying oder name-update.
426
- if (saveResult.isNew !== true) return;
427
- const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
428
- // skip: defensive type-guard. Tenant-entity hat id zwingend, aber
429
- // CrudExecutor's payload-shape ist runtime-unknown.
430
- if (typeof data.id !== "string") return;
431
- const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
432
- const aggregateId = tierAssignmentAggregateId(newTenantId);
433
-
434
- // tenant is an r.systemScope() feature, so this cross-feature hook gets a
435
- // fail-closed ctx.db — reach for ctx.systemDb instead (see entity-handlers.ts).
436
- // Event-store paths need the raw DbConnection — TenantDb only exposes
437
- // select/insert/update/delete, not execute (db.execute(sql`SELECT pg_notify(...)`) would TypeError).
438
- const rawDb = ctx.systemDb
439
- ? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
440
- : ctx.db && "unsafeRaw" in ctx.db
441
- ? ctx.db.unsafeRaw()
442
- : undefined;
443
- // skip: defensive — inTransaction phase always sets db, but AppContext's
444
- // type makes it optional. Throwing would be overreach (lifecycle
445
- // blocking), silent-skip is defensive-soft.
446
- if (!rawDb) return;
447
-
448
- // Idempotency: stream-existence-check vor create. Pattern aus
449
- // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
450
- const streamVersion = await getUnscopedAggregateStreamMaxVersion(rawDb, aggregateId);
451
- // skip: idempotent — tier-assignment stream already seeded (rebuild/replay).
452
- if (streamVersion > 0) return;
453
-
454
- // SystemUser für den NEUEN tenant — der Hook wird vom signup-
455
- // user (anderer tenant, oder SystemAdmin) ausgelöst, aber das
456
- // tier-assignment muss im stream des neu-erzeugten tenants
457
- // landen (= aggregate-id deterministic auf newTenantId). Memory
458
- // `feedback_event_store_tenant_consistency`: by.tenantId muss =
459
- // ziel-tenant.
460
- const systemUser: SessionUser = {
461
- id: "00000000-0000-4000-8000-000000000001",
462
- tenantId: newTenantId,
463
- roles: ["SystemAdmin"],
464
- };
465
- const tdb = createTenantDb(rawDb, newTenantId, "system");
466
-
467
- await tierAssignmentExecutor.create(
468
- { id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
469
- systemUser,
470
- tdb,
471
- );
378
+ target: { allOf: "tier-assignment" },
379
+ handler: async (result) => {
380
+ // result.data has tenantId + tier (after entity-update merge)
381
+ const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
382
+ // skip: defensive guard on the payload shape. Correct entity events
383
+ // always carry both fields as strings; a malformed payload (a custom
384
+ // handler bug) skips the cache update instead of throwing, because a
385
+ // hook error must not block the lifecycle pipeline (afterCommit,
386
+ // best-effort side effect).
387
+ if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
388
+ applyAssignment(data.tenantId as TenantId, data.tier);
472
389
  },
473
- phase: HookPhases.inTransaction,
474
- escapeHatch: { reason: autoDefaultTierHookReason },
475
390
  });
476
- }
477
-
478
- // Extension-point declaration + self-registration. Pattern analog
479
- // mail-foundation/file-foundation: das feature deklariert den
480
- // extension-point UND registriert sich als default-plugin. Andere
481
- // features könnten später auch dort registrieren (z.B. ein future
482
- // toggle-only-resolver), aber nur ein plugin gewinnt — siehe
483
- // findTierResolverUsage's "single-plugin"-comment.
484
- r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
485
-
486
- // Plugin-registration: framework's runDevApp/runProdApp picks this up
487
- // post-stack-setup, calls build(deps), wires effectiveFeatures.
488
- const plugin: TierResolverPlugin = {
489
- build: async (deps) => {
490
- // Always-on-Set: alle non-toggleable features im registry. Canonical
491
- // Kumiko-pattern (matches feature-toggles' resolver): nur features
492
- // die explizit `r.toggleable()` registrieren sind tier-cuttable.
493
- // Framework-base-features (auth-email-password, config, user, tenant,
494
- // sessions, etc.) sind non-toggleable → IMMER aktiv für jeden Tenant
495
- // unabhängig vom tier. Sonst würde dispatcher 403 auf login-handler
496
- // werfen weil "feature auth-email-password disabled".
497
- const computedAlwaysOn = new Set<string>();
498
- for (const feature of deps.registry.features.values()) {
499
- if (feature.toggleableDefault === undefined) computedAlwaysOn.add(feature.name);
500
- }
501
- alwaysOnHolder.set = computedAlwaysOn;
502
-
503
- // Pre-load all existing assignments into cache. SaaS-Apps haben
504
- // typischerweise <100k tenants — single-pass scan akzeptabel.
505
- // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
506
- // Bedürfnis entsteht.
507
- type AssignmentRow = { tenantId: string; tier: string };
508
- const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
509
- for (const row of rows) {
510
- cache.set(
511
- row.tenantId as TenantId,
512
- mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
513
- );
514
- }
515
-
516
- // Read first, then set — never clear-then-refill: a cache miss falls back
517
- // to the default tier, which would lock paying tenants out meanwhile.
518
- const reloadTenant = async (tenantId: TenantId): Promise<void> => {
519
- const ticket = takeTicket(tenantId);
520
- const row = await fetchOne<AssignmentRow>(deps.db, tierAssignmentTable, { tenantId });
521
- // skip: a newer write or reload for this tenant started meanwhile and owns the entry
522
- if (latestTicket.get(tenantId) !== ticket) return;
523
- if (row) {
524
- cache.set(
525
- tenantId,
526
- mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
391
+ r.hook({
392
+ type: "postDelete",
393
+ target: { allOf: "tier-assignment" },
394
+ handler: async (payload) => {
395
+ const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
396
+ // skip: same type-guard semantics as the postSave hook above.
397
+ if (typeof data.tenantId !== "string") return;
398
+ const tenantId = data.tenantId as TenantId;
399
+ takeTicket(tenantId);
400
+ cache.delete(tenantId);
401
+ publishAssignmentChange(tenantId);
402
+ },
403
+ });
404
+
405
+ // Auto-default-tier-on-tenant-signup: the hook fires inTransaction (atomic
406
+ // with the tenant-create rollback) and writes the tier-assignment via the
407
+ // direct executor (not ctx.write — the hook has an AppContext). Same
408
+ // pattern as seedTenant in tenant/seeding.ts.
409
+ //
410
+ // **Idempotency:** deterministic aggregate id derived from tenantId. A
411
+ // re-replay (after a projection rebuild or hook retry) finds
412
+ // stream-version > 0 and skips instead of throwing version_conflict. The
413
+ // caller sees a successful tenant-create plus the existing
414
+ // tier-assignment.
415
+ //
416
+ // **Cross-tenant write:** the executor needs a systemUser WITH tenantId =
417
+ // the new tenant (memory `feedback_event_store_tenant_consistency`).
418
+ if (opts.defaultTier !== undefined) {
419
+ const defaultTier = opts.defaultTier;
420
+ const autoDefaultTierHookReason =
421
+ "seeds the default tier-assignment stream of the tenant just created";
422
+ r.hook({
423
+ type: "postSave",
424
+ target: { allOf: "tenant" },
425
+ handler: async (result, ctx) => {
426
+ // result shape: kumiko-framework's SaveContext with isNew + data
427
+ const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
428
+ // skip: only on tenant-create (initial) — tenant updates fire
429
+ // postSave too, but a re-keying or name update must not create a
430
+ // new tier-assignment.
431
+ if (saveResult.isNew !== true) return;
432
+ const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
433
+ // skip: defensive type guard. The tenant entity always has an id, but
434
+ // CrudExecutor's payload shape is unknown at runtime.
435
+ if (typeof data.id !== "string") return;
436
+ const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
437
+ const aggregateId = tierAssignmentAggregateId(newTenantId);
438
+
439
+ // tenant is an r.systemScope() feature, so this cross-feature hook gets a
440
+ // fail-closed ctx.db — reach for ctx.systemDb instead (see entity-handlers.ts).
441
+ // Event-store paths need the raw DbConnection — TenantDb only exposes
442
+ // select/insert/update/delete, not execute (db.execute(sql`SELECT pg_notify(...)`) would TypeError).
443
+ const rawDb = ctx.systemDb
444
+ ? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
445
+ : ctx.db && "unsafeRaw" in ctx.db
446
+ ? ctx.db.unsafeRaw()
447
+ : undefined;
448
+ // skip: defensive — inTransaction phase always sets db, but AppContext's
449
+ // type makes it optional. Throwing would be overreach (lifecycle
450
+ // blocking), silent-skip is defensive-soft.
451
+ if (!rawDb) return;
452
+
453
+ // Idempotency: stream-existence check before create. Same pattern as
454
+ // seedTenant.ts. On a re-replay (rebuild) do not bump the version.
455
+ const streamVersion = await getUnscopedAggregateStreamMaxVersion(rawDb, aggregateId);
456
+ // skip: idempotent — tier-assignment stream already seeded (rebuild/replay).
457
+ if (streamVersion > 0) return;
458
+
459
+ // SystemUser for the NEW tenant — the hook is triggered by the signup
460
+ // user (another tenant, or SystemAdmin), but the tier-assignment has
461
+ // to land in the stream of the newly created tenant (= aggregate id
462
+ // deterministic on newTenantId). Memory
463
+ // `feedback_event_store_tenant_consistency`: by.tenantId must equal
464
+ // the target tenant.
465
+ const systemUser: SessionUser = {
466
+ id: "00000000-0000-4000-8000-000000000001",
467
+ tenantId: newTenantId,
468
+ roles: ["SystemAdmin"],
469
+ };
470
+ const tdb = createTenantDb({ db: rawDb, tenantId: newTenantId, mode: "system" });
471
+
472
+ await tierAssignmentExecutor.create(
473
+ { id: aggregateId, tier: defaultTier, source: TierAssignmentSources.default },
474
+ systemUser,
475
+ tdb,
527
476
  );
528
- } else {
529
- cache.delete(tenantId);
477
+ },
478
+ phase: HookPhases.inTransaction,
479
+ escapeHatch: { reason: autoDefaultTierHookReason },
480
+ });
481
+ }
482
+
483
+ // Extension-point declaration + self-registration. Same pattern as
484
+ // mail-foundation/file-foundation: the feature declares the extension
485
+ // point AND registers itself as the default plugin. Other features could
486
+ // register there later (e.g. a future toggle-only resolver), but only one
487
+ // plugin wins — see the "single-plugin" comment on findTierResolverUsage.
488
+ r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
489
+
490
+ // Plugin-registration: framework's runDevApp/runProdApp picks this up
491
+ // post-stack-setup, calls build(deps), wires effectiveFeatures.
492
+ const plugin: TierResolverPlugin = {
493
+ build: async (deps) => {
494
+ // Always-on set: all non-toggleable features in the registry. Canonical
495
+ // Kumiko pattern (matches the feature-toggles resolver): only features
496
+ // that explicitly register `r.toggleable()` are tier-cuttable.
497
+ // Framework base features (auth-email-password, config, user, tenant,
498
+ // sessions, etc.) are non-toggleable → ALWAYS active for every tenant
499
+ // regardless of tier. Otherwise the dispatcher would answer 403 on the
500
+ // login handler because "feature auth-email-password disabled".
501
+ const computedAlwaysOn = new Set<string>();
502
+ for (const feature of deps.registry.features.values()) {
503
+ if (feature.toggleableDefault === undefined) computedAlwaysOn.add(feature.name);
530
504
  }
531
- };
532
- const reloadAllTenants = async (): Promise<void> => {
533
- ticketCounter += 1;
534
- const startedAtTicket = ticketCounter;
535
- const current = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
536
- const loaded = new Map<TenantId, ReadonlySet<string>>();
537
- for (const row of current) {
538
- loaded.set(
505
+ alwaysOnHolder.set = computedAlwaysOn;
506
+
507
+ // Pre-load all existing assignments into the cache. SaaS apps typically
508
+ // have <100k tenants, so a single-pass scan is acceptable. The scaling
509
+ // path (lazy load + LRU) is Sprint-8b, once a real need arises.
510
+ type AssignmentRow = { tenantId: string; tier: string };
511
+ const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
512
+ for (const row of rows) {
513
+ cache.set(
539
514
  row.tenantId as TenantId,
540
515
  mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
541
516
  );
542
517
  }
543
- const isNewerThanSnapshot = (tenantId: TenantId): boolean =>
544
- (latestTicket.get(tenantId) ?? 0) > startedAtTicket;
545
- for (const tenantId of [...cache.keys()]) {
546
- if (!loaded.has(tenantId) && !isNewerThanSnapshot(tenantId)) {
518
+
519
+ // Read first, then set — never clear-then-refill: a cache miss falls back
520
+ // to the default tier, which would lock paying tenants out meanwhile.
521
+ const reloadTenant = async (tenantId: TenantId): Promise<void> => {
522
+ const ticket = takeTicket(tenantId);
523
+ const row = await fetchOne<AssignmentRow>(deps.db, tierAssignmentTable, { tenantId });
524
+ // skip: a newer write or reload for this tenant started meanwhile and owns the entry
525
+ if (latestTicket.get(tenantId) !== ticket) return;
526
+ if (row) {
527
+ cache.set(
528
+ tenantId,
529
+ mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
530
+ );
531
+ } else {
547
532
  cache.delete(tenantId);
548
- latestTicket.set(tenantId, startedAtTicket);
549
533
  }
550
- }
551
- for (const [tenantId, features] of loaded) {
552
- if (!isNewerThanSnapshot(tenantId)) {
553
- cache.set(tenantId, features);
554
- // Makes a single reload that started before this snapshot discard its older result.
555
- latestTicket.set(tenantId, startedAtTicket);
534
+ };
535
+ const reloadAllTenants = async (): Promise<void> => {
536
+ ticketCounter += 1;
537
+ const startedAtTicket = ticketCounter;
538
+ const current = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
539
+ const loaded = new Map<TenantId, ReadonlySet<string>>();
540
+ for (const row of current) {
541
+ loaded.set(
542
+ row.tenantId as TenantId,
543
+ mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
544
+ );
556
545
  }
557
- }
558
- };
559
-
560
- busHolder.bus = deps.cacheSync;
561
- deps.cacheSync?.subscribe(CACHE_SYNC_TOPICS.tierAssignment, (message) => {
562
- // skip: malformed message from another process
563
- if (!isTierAssignmentSyncMessage(message)) return;
564
- reloadTenant(message.tenantId).catch((err: unknown) => {
565
- log.error("tier reload failed, keeping cached set", {
566
- tenantId: message.tenantId,
567
- err,
546
+ const isNewerThanSnapshot = (tenantId: TenantId): boolean =>
547
+ (latestTicket.get(tenantId) ?? 0) > startedAtTicket;
548
+ for (const tenantId of [...cache.keys()]) {
549
+ if (!loaded.has(tenantId) && !isNewerThanSnapshot(tenantId)) {
550
+ cache.delete(tenantId);
551
+ latestTicket.set(tenantId, startedAtTicket);
552
+ }
553
+ }
554
+ for (const [tenantId, features] of loaded) {
555
+ if (!isNewerThanSnapshot(tenantId)) {
556
+ cache.set(tenantId, features);
557
+ // Makes a single reload that started before this snapshot discard its older result.
558
+ latestTicket.set(tenantId, startedAtTicket);
559
+ }
560
+ }
561
+ };
562
+
563
+ busHolder.bus = deps.cacheSync;
564
+ deps.cacheSync?.subscribe(CACHE_SYNC_TOPICS.tierAssignment, (message) => {
565
+ // skip: malformed message from another process
566
+ if (!isTierAssignmentSyncMessage(message)) return;
567
+ reloadTenant(message.tenantId).catch((err: unknown) => {
568
+ log.error("tier reload failed, keeping cached set", {
569
+ tenantId: message.tenantId,
570
+ err,
571
+ });
568
572
  });
569
573
  });
570
- });
571
- deps.cacheSync?.onResync(() => {
572
- reloadAllTenants().catch((err: unknown) => {
573
- log.error("tier resync failed, keeping cached sets", { err });
574
+ deps.cacheSync?.onResync(() => {
575
+ reloadAllTenants().catch((err: unknown) => {
576
+ log.error("tier resync failed, keeping cached sets", { err });
577
+ });
574
578
  });
575
- });
576
579
 
577
- // Synchronous resolver-callback for dispatcher hot-path.
578
- const resolver = (tenantId: TenantId): ReadonlySet<string> => {
579
- // Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
580
- // gets the union of all tier-features (siehe DispatcherOptions doc).
581
- if (tenantId === SYSTEM_TENANT_ID) {
582
- return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
583
- }
584
- // Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
585
- // boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
586
- // ist least-privileged — typisch Free-Tier-features. Memory
587
- // `feedback_security_default_on`: secure-by-default. Der Trial wird
588
- // hier NICHT addiert (sync/boot-cached sieht den Zeitablauf nicht) —
589
- // das macht der trialGate unten am disabled-Gate-Pfad.
590
- const fallbackTier = opts.defaultTier;
591
- return (
592
- cache.get(tenantId) ??
593
- (fallbackTier === undefined
594
- ? computedAlwaysOn
595
- : mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)))
596
- );
597
- };
598
-
599
- const trial = opts.trial;
600
- if (trial === undefined) return resolver;
601
-
602
- // Live-Trial-Gate: liest tenant.inserted_at (≈ Signup, existiert für
603
- // JEDEN Tenant inkl. auth-signup via seedTenant — anders als die
604
- // tier-assignment-Row, die der seed-Pfad nicht anlegt) und prüft das
605
- // Fenster gegen die aktuelle Zeit. Nur auf dem disabled-Gate-Pfad
606
- // konsultiert. inserted_at ist immutable → pro Tenant einmal lesen.
607
- // Promise-memo (not a plain value cache): N concurrent requests for the
608
- // same fresh tenant must await the SAME in-flight fetchOne, not each
609
- // fire their own — a plain `Map<TenantId, number>` set AFTER the await
610
- // lets every concurrent caller miss the cache and issue a redundant
611
- // read (benign but wasteful on a signup burst).
612
- const startedMemo = new Map<TenantId, Promise<number | undefined>>();
613
- const trialGate: TrialGate = async (tenantId, featureName) => {
614
- if (!trialFeatures.has(featureName)) return false;
615
- let startedMsPromise = startedMemo.get(tenantId);
616
- if (startedMsPromise === undefined) {
617
- startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
618
- id: tenantId,
619
- }).then((row) => row?.insertedAt?.epochMilliseconds);
620
- // Evict on rejection too — a transient DB error must not poison
621
- // every future call for this tenant with the same cached rejection.
622
- startedMsPromise.catch(() => startedMemo.delete(tenantId));
623
- startedMemo.set(tenantId, startedMsPromise);
624
- }
625
- const startedMs = await startedMsPromise;
626
- // Tenant-Row noch nicht projiziert (Replay-Race) → keinen Miss
627
- // memoizen, beim nächsten Request neu lesen.
628
- if (startedMs === undefined) {
629
- startedMemo.delete(tenantId);
630
- return false;
631
- }
632
- return isTrialActive(startedMs, nowMs(), trial.durationHours);
633
- };
634
- return Object.assign(resolver, { trialGate });
635
- },
636
- };
637
- // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a framework registrar method, not a React hook.
638
- r.useExtension(TENANT_TIER_RESOLVER_EXT, TIER_ENGINE_FEATURE, plugin);
639
- });
580
+ // Synchronous resolver-callback for dispatcher hot-path.
581
+ const resolver = (tenantId: TenantId): ReadonlySet<string> => {
582
+ // Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
583
+ // gets the union of all tier features (see DispatcherOptions doc).
584
+ if (tenantId === SYSTEM_TENANT_ID) {
585
+ return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
586
+ }
587
+ // Cache miss: the tenant is not in the cache yet (e.g. brand new after
588
+ // boot, or the defaultTier hook has not fired yet). The default set
589
+ // is least-privileged — typically free-tier features. Memory
590
+ // `feedback_security_default_on`: secure by default. The trial is NOT
591
+ // added here (sync/boot-cached, cannot see the passage of time) — the
592
+ // trialGate below does that on the disabled-gate path.
593
+ const fallbackTier = opts.defaultTier;
594
+ return (
595
+ cache.get(tenantId) ??
596
+ (fallbackTier === undefined
597
+ ? computedAlwaysOn
598
+ : mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)))
599
+ );
600
+ };
601
+
602
+ const trial = opts.trial;
603
+ if (trial === undefined) return resolver;
604
+
605
+ // Live trial gate: reads tenant.inserted_at (≈ signup; exists for EVERY
606
+ // tenant incl. auth-signup via seedTenant — unlike the tier-assignment
607
+ // row, which the seed path does not create) and checks the window
608
+ // against the current time. Consulted only on the disabled-gate path.
609
+ // inserted_at is immutable → read once per tenant.
610
+ // Promise-memo (not a plain value cache): N concurrent requests for the
611
+ // same fresh tenant must await the SAME in-flight fetchOne, not each
612
+ // fire their own — a plain `Map<TenantId, number>` set AFTER the await
613
+ // lets every concurrent caller miss the cache and issue a redundant
614
+ // read (benign but wasteful on a signup burst).
615
+ const startedMemo = new Map<TenantId, Promise<number | undefined>>();
616
+ const trialGate: TrialGate = async (tenantId, featureName) => {
617
+ if (!trialFeatures.has(featureName)) return false;
618
+ let startedMsPromise = startedMemo.get(tenantId);
619
+ if (startedMsPromise === undefined) {
620
+ startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
621
+ id: tenantId,
622
+ }).then((row) => row?.insertedAt?.epochMilliseconds);
623
+ // Evict on rejection too — a transient DB error must not poison
624
+ // every future call for this tenant with the same cached rejection.
625
+ startedMsPromise.catch(() => startedMemo.delete(tenantId));
626
+ startedMemo.set(tenantId, startedMsPromise);
627
+ }
628
+ const startedMs = await startedMsPromise;
629
+ // Tenant row not projected yet (replay race) → do not memoize the
630
+ // miss, re-read on the next request.
631
+ if (startedMs === undefined) {
632
+ startedMemo.delete(tenantId);
633
+ return false;
634
+ }
635
+ return isTrialActive(startedMs, nowMs(), trial.durationHours);
636
+ };
637
+ return Object.assign(resolver, { trialGate });
638
+ },
639
+ };
640
+ // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a framework registrar method, not a React hook.
641
+ r.useExtension(TENANT_TIER_RESOLVER_EXT, TIER_ENGINE_FEATURE, plugin);
642
+ },
643
+ Object.keys(opts).length === 0 ? { dedupeOptions: {} } : undefined,
644
+ );
640
645
  }
641
646
 
642
- /**
643
- * Legacy named export — equivalent to `createTierEngineFeature()` ohne opts.
644
- * Storage-only mode (no resolver, no auto-default). Existing apps die
645
- * `tierEngineFeature` referenzieren bekommen identical behavior.
646
- *
647
- * Migration zu `createTierEngineFeature({ defaultTier, tierMap })` gibt
648
- * volle Tier-Composition (Sprint-8a Pattern).
649
- */
647
+ /** @deprecated Use createTierEngineFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
650
648
  export const tierEngineFeature: FeatureDefinition = createTierEngineFeature();