@cosmicdrift/kumiko-framework 1.0.0 → 2.0.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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
package/README.md CHANGED
@@ -28,9 +28,10 @@ Bun is the intended runtime and test runner.
28
28
  ## At-a-glance
29
29
 
30
30
  ```typescript
31
- import { defineFeature, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
31
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
32
 
33
33
  export const taskEntity = createEntity({
34
+ table: "read_tasks",
34
35
  fields: {
35
36
  title: createTextField({ required: true, searchable: true }),
36
37
  done: createTextField(),
@@ -38,44 +39,14 @@ export const taskEntity = createEntity({
38
39
  softDelete: true,
39
40
  });
40
41
 
41
- const taskTable = buildDrizzleTable("task", taskEntity);
42
- const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
43
-
44
42
  export const taskFeature = defineFeature("tasks", (r) => {
45
- r.entity("task", taskEntity);
46
-
47
- // Write handlers go through createEventStoreExecutor events + projection in one TX,
48
- // optimistic locking, access control, all explicit.
49
- r.writeHandler(
50
- "task:create",
51
- z.object({ title: z.string() }),
52
- async (event, ctx) => taskExecutor.create(event.payload, event.user, ctx.db),
53
- { access: { roles: ["User"] } },
54
- );
55
- r.writeHandler(
56
- "task:update",
57
- z.object({ id: z.uuid(), version: z.number(), changes: z.object({ title: z.string().optional() }) }),
58
- async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
59
- { access: { roles: ["User"] } },
60
- );
61
- r.writeHandler(
62
- "task:delete",
63
- z.object({ id: z.uuid() }),
64
- async (event, ctx) => taskExecutor.delete(event.payload, event.user, ctx.db),
65
- { access: { roles: ["Admin"] } },
66
- );
67
- r.queryHandler(
68
- "task:list",
69
- z.object({}),
70
- async (query, ctx) => taskExecutor.list(query.payload, query.user, ctx.db),
71
- { access: { openToAll: true } },
72
- );
73
- r.queryHandler(
74
- "task:detail",
75
- z.object({ id: z.uuid() }),
76
- async (query, ctx) => taskExecutor.detail(query.payload, query.user, ctx.db),
77
- { access: { openToAll: true } },
78
- );
43
+ // r.crud wires create/update/delete/restore + list/detail queries in one
44
+ // call — events + projection in the same TX, optimistic locking, access
45
+ // control, all explicit via the options below.
46
+ r.crud("task", taskEntity, {
47
+ write: { access: { roles: ["User"] } },
48
+ read: { access: { openToAll: true } },
49
+ });
79
50
 
80
51
  // Read-model fed from task events, rebuildable via the CLI
81
52
  r.projection({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -99,6 +99,10 @@
99
99
  "types": "./src/jobs/index.ts",
100
100
  "default": "./src/jobs/index.ts"
101
101
  },
102
+ "./observability": {
103
+ "types": "./src/observability/index.ts",
104
+ "default": "./src/observability/index.ts"
105
+ },
102
106
  "./migrations": {
103
107
  "types": "./src/migrations/index.ts",
104
108
  "default": "./src/migrations/index.ts"
@@ -131,10 +135,22 @@
131
135
  "types": "./src/secrets/index.ts",
132
136
  "default": "./src/secrets/index.ts"
133
137
  },
138
+ "./crypto": {
139
+ "types": "./src/crypto/index.ts",
140
+ "default": "./src/crypto/index.ts"
141
+ },
142
+ "./logging": {
143
+ "types": "./src/logging/index.ts",
144
+ "default": "./src/logging/index.ts"
145
+ },
134
146
  "./schema-cli": {
135
147
  "types": "./src/schema-cli.ts",
136
148
  "default": "./src/schema-cli.ts"
137
149
  },
150
+ "./consumer-cli": {
151
+ "types": "./src/consumer-cli.ts",
152
+ "default": "./src/consumer-cli.ts"
153
+ },
138
154
  "./stack": {
139
155
  "types": "./src/stack/index.ts",
140
156
  "default": "./src/stack/index.ts"
@@ -180,8 +196,11 @@
180
196
  "uuid": "^14.0.0",
181
197
  "zod": "^4.4.3"
182
198
  },
199
+ "peerDependencies": {
200
+ "@cosmicdrift/kumiko-types": "^2.0.0"
201
+ },
183
202
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "1.0.0",
203
+ "@cosmicdrift/kumiko-dispatcher-live": "2.0.0",
185
204
  "bun-types": "^1.3.13",
186
205
  "pino-pretty": "^13.1.3"
187
206
  },
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
10
  import { z } from "zod";
11
+ import type { AnonymousAccessConfig } from "../api/auth-middleware";
11
12
  import { createEventStoreExecutor } from "../db/event-store-executor";
12
13
  import { asRawClient, selectMany } from "../db/query";
13
14
  import { buildEntityTable } from "../db/table-builder";
@@ -307,6 +308,190 @@ describe("anonymous access — header-supplied tenant", () => {
307
308
  });
308
309
  });
309
310
 
311
+ describe("anonymous access — resolverTrust: authoritative", () => {
312
+ let stack: TestStack;
313
+
314
+ beforeAll(async () => {
315
+ // Simulates a subdomain resolver: TENANT_ID is "known" (the resolver
316
+ // recognises it), everything else is an unrecognised host (null).
317
+ stack = await setupTestStack({
318
+ features: [shopFeature],
319
+ anonymousAccess: {
320
+ tenantResolver: () => TENANT_ID,
321
+ resolverTrust: "authoritative",
322
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
323
+ },
324
+ });
325
+ await unsafeCreateEntityTable(stack.db, productEntity);
326
+ await unsafeCreateEntityTable(stack.db, orderEntity);
327
+ });
328
+
329
+ afterAll(() => stack.cleanup());
330
+
331
+ test("no client tenant → resolver wins", async () => {
332
+ const res = await stack.http.raw("POST", "/api/query", {
333
+ type: "anonshop:query:product:list",
334
+ payload: {},
335
+ });
336
+ expect(res.status).toBe(200);
337
+ });
338
+
339
+ test("X-Tenant header agreeing with the resolver → accepted", async () => {
340
+ const res = await stack.http.raw(
341
+ "POST",
342
+ "/api/query",
343
+ { type: "anonshop:query:product:list", payload: {} },
344
+ { "X-Tenant": TENANT_ID },
345
+ );
346
+ expect(res.status).toBe(200);
347
+ });
348
+
349
+ test("X-Tenant header disagreeing with the resolver → 400 tenant_mismatch, resolver's tenant is NOT overridden", async () => {
350
+ // The core #51 regression: a header claiming a different (real, active)
351
+ // tenant than the one the resolver derived must never win — that would
352
+ // let a guest on tenantA's subdomain write into tenantB by forging a
353
+ // header. Reject, don't silently prefer either side.
354
+ const res = await stack.http.raw(
355
+ "POST",
356
+ "/api/query",
357
+ { type: "anonshop:query:product:list", payload: {} },
358
+ { "X-Tenant": OTHER_TENANT_ID },
359
+ );
360
+ expect(res.status).toBe(400);
361
+ const body = (await res.json()) as { error: { code: string } };
362
+ expect(body.error.code).toBe("tenant_mismatch");
363
+ });
364
+
365
+ // pr-review kumiko-framework #1052/3 — resolveTenant treats header and
366
+ // cookie identically, but every case above only exercised the header.
367
+ // These two lock in that the cookie path behaves the same way.
368
+ test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
369
+ const res = await stack.http.raw(
370
+ "POST",
371
+ "/api/query",
372
+ { type: "anonshop:query:product:list", payload: {} },
373
+ { Cookie: `kumiko_tenant=${TENANT_ID}` },
374
+ );
375
+ expect(res.status).toBe(200);
376
+ });
377
+
378
+ test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
379
+ const res = await stack.http.raw(
380
+ "POST",
381
+ "/api/query",
382
+ { type: "anonshop:query:product:list", payload: {} },
383
+ { Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
384
+ );
385
+ expect(res.status).toBe(400);
386
+ const body = (await res.json()) as { error: { code: string } };
387
+ expect(body.error.code).toBe("tenant_mismatch");
388
+ });
389
+ });
390
+
391
+ describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
392
+ let stack: TestStack;
393
+
394
+ beforeAll(async () => {
395
+ // Resolver never recognises the host (e.g. an unmapped/unknown
396
+ // subdomain) — always returns null.
397
+ stack = await setupTestStack({
398
+ features: [shopFeature],
399
+ anonymousAccess: {
400
+ tenantResolver: () => null,
401
+ resolverTrust: "authoritative",
402
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
403
+ },
404
+ });
405
+ await unsafeCreateEntityTable(stack.db, productEntity);
406
+ await unsafeCreateEntityTable(stack.db, orderEntity);
407
+ });
408
+
409
+ afterAll(() => stack.cleanup());
410
+
411
+ test("resolver null + X-Tenant header present → 400 tenant_required, header does NOT pick the tenant", async () => {
412
+ // If this fell back to the client header, an unrecognised-host request
413
+ // could still choose its own tenant via X-Tenant — the same override
414
+ // "authoritative" exists to prevent, just reached via "unknown host"
415
+ // instead of "known host, disagreeing header".
416
+ const res = await stack.http.raw(
417
+ "POST",
418
+ "/api/query",
419
+ { type: "anonshop:query:product:list", payload: {} },
420
+ { "X-Tenant": TENANT_ID },
421
+ );
422
+ expect(res.status).toBe(400);
423
+ const body = (await res.json()) as { error: { code: string } };
424
+ expect(body.error.code).toBe("tenant_required");
425
+ });
426
+
427
+ test("resolver null + no client tenant → 400 tenant_required", async () => {
428
+ const res = await stack.http.raw("POST", "/api/query", {
429
+ type: "anonshop:query:product:list",
430
+ payload: {},
431
+ });
432
+ expect(res.status).toBe(400);
433
+ const body = (await res.json()) as { error: { code: string } };
434
+ expect(body.error.code).toBe("tenant_required");
435
+ });
436
+ });
437
+
438
+ describe("anonymous access — resolverTrust: fallback-only", () => {
439
+ let stack: TestStack;
440
+
441
+ beforeAll(async () => {
442
+ stack = await setupTestStack({
443
+ features: [shopFeature],
444
+ anonymousAccess: {
445
+ tenantResolver: () => TENANT_ID,
446
+ resolverTrust: "fallback-only",
447
+ tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
448
+ },
449
+ });
450
+ await unsafeCreateEntityTable(stack.db, productEntity);
451
+ await unsafeCreateEntityTable(stack.db, orderEntity);
452
+ });
453
+
454
+ afterAll(() => stack.cleanup());
455
+
456
+ test("X-Tenant header disagreeing with the resolver → header wins (documented convenience-fallback behaviour)", async () => {
457
+ // Opposite of "authoritative": a client tenant that disagrees with the
458
+ // resolver is accepted, not rejected — this mode is for resolvers that
459
+ // carry no more trust than the client's own claim.
460
+ const res = await stack.http.raw(
461
+ "POST",
462
+ "/api/query",
463
+ { type: "anonshop:query:product:list", payload: {} },
464
+ { "X-Tenant": OTHER_TENANT_ID },
465
+ );
466
+ expect(res.status).toBe(200);
467
+ });
468
+
469
+ test("no client tenant → resolver runs as fallback", async () => {
470
+ const res = await stack.http.raw("POST", "/api/query", {
471
+ type: "anonshop:query:product:list",
472
+ payload: {},
473
+ });
474
+ expect(res.status).toBe(200);
475
+ });
476
+ });
477
+
478
+ describe("anonymous access — tenantResolver without resolverTrust", () => {
479
+ test("authMiddleware throws at boot, not silently at request time", async () => {
480
+ await expect(
481
+ setupTestStack({
482
+ features: [shopFeature],
483
+ // Simulates a caller that bypasses the compiler (JS consumer, an
484
+ // `as any` cast) — the discriminated union stops well-typed callers,
485
+ // this proves the runtime guard behind it also fires.
486
+ anonymousAccess: {
487
+ tenantResolver: () => TENANT_ID,
488
+ resolverTrust: undefined,
489
+ } as unknown as AnonymousAccessConfig,
490
+ }),
491
+ ).rejects.toThrow(/resolverTrust/);
492
+ });
493
+ });
494
+
310
495
  describe("anonymous access — disabled by default", () => {
311
496
  let stack: TestStack;
312
497
 
@@ -0,0 +1,172 @@
1
+ // Integration test for `runConsumerCli` (kumiko-framework#1351).
2
+ //
3
+ // Recovery semantics themselves are already covered by
4
+ // event-dispatcher-recovery — this only exercises the CLI layer: argv
5
+ // parsing, DB-connection handling, exit codes, output formatting.
6
+
7
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
8
+ import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
9
+ import { type ConsumerCliOut, runConsumerCli } from "../consumer-cli";
10
+ import { insertConsumerIfAbsent, markConsumerRebuildFailed } from "../db/queries/event-consumer";
11
+ import { asRawClient } from "../db/query";
12
+ import { createEventConsumerStateTable } from "../pipeline";
13
+ import { ensureTemporalPolyfill } from "../time/polyfill";
14
+
15
+ const SHARED = "__shared__";
16
+
17
+ let testDb: BunTestDb;
18
+ let testUrl: string;
19
+ let prevDbUrl: string | undefined;
20
+
21
+ beforeAll(async () => {
22
+ await ensureTemporalPolyfill();
23
+ testDb = await createTestDb();
24
+ const baseUrl = process.env["TEST_DATABASE_URL"];
25
+ if (!baseUrl) throw new Error("TEST_DATABASE_URL not set — required for this test file");
26
+ testUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
27
+ await createEventConsumerStateTable(testDb.db);
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await testDb.cleanup();
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await asRawClient(testDb.db).unsafe(`TRUNCATE TABLE "kumiko_event_consumers"`);
36
+ if (prevDbUrl === undefined) delete process.env["DATABASE_URL"];
37
+ else process.env["DATABASE_URL"] = prevDbUrl;
38
+ });
39
+
40
+ function captureOut(): { out: ConsumerCliOut; lines: string[] } {
41
+ const lines: string[] = [];
42
+ return {
43
+ out: { log: (l: string) => lines.push(l), err: (l: string) => lines.push(`ERR ${l}`) },
44
+ lines,
45
+ };
46
+ }
47
+
48
+ describe("runConsumerCli status", () => {
49
+ test("unknown consumer → exit 1, not-found message", async () => {
50
+ prevDbUrl = process.env["DATABASE_URL"];
51
+ process.env["DATABASE_URL"] = testUrl;
52
+ const { out, lines } = captureOut();
53
+ const code = await runConsumerCli(["status", "no-such-consumer"], out);
54
+ expect(code).toBe(1);
55
+ expect(lines.join("\n")).toContain("not found");
56
+ });
57
+
58
+ test("known consumer → exit 0, reports status/cursor/attempts", async () => {
59
+ prevDbUrl = process.env["DATABASE_URL"];
60
+ process.env["DATABASE_URL"] = testUrl;
61
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:foo", SHARED);
62
+ const { out, lines } = captureOut();
63
+ const code = await runConsumerCli(["status", "test:consumer:foo"], out);
64
+ expect(code).toBe(0);
65
+ const joined = lines.join("\n");
66
+ expect(joined).toContain("test:consumer:foo");
67
+ expect(joined).toContain("status: idle");
68
+ });
69
+
70
+ test("missing <name> → exit 1, usage message", async () => {
71
+ prevDbUrl = process.env["DATABASE_URL"];
72
+ process.env["DATABASE_URL"] = testUrl;
73
+ const { out, lines } = captureOut();
74
+ const code = await runConsumerCli(["status"], out);
75
+ expect(code).toBe(1);
76
+ expect(lines.join("\n")).toContain("Usage:");
77
+ });
78
+
79
+ test("missing DATABASE_URL → exit 1", async () => {
80
+ prevDbUrl = process.env["DATABASE_URL"];
81
+ delete process.env["DATABASE_URL"];
82
+ const { out, lines } = captureOut();
83
+ const code = await runConsumerCli(["status", "whatever"], out);
84
+ expect(code).toBe(1);
85
+ expect(lines.join("\n")).toContain("DATABASE_URL not set");
86
+ });
87
+
88
+ test("--instance-id flag → status looks up the per-instance consumer, not shared", async () => {
89
+ prevDbUrl = process.env["DATABASE_URL"];
90
+ process.env["DATABASE_URL"] = testUrl;
91
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:inst", "inst-1");
92
+ const { out, lines } = captureOut();
93
+ const code = await runConsumerCli(
94
+ ["status", "test:consumer:inst", "--instance-id", "inst-1"],
95
+ out,
96
+ );
97
+ expect(code).toBe(0);
98
+ const joined = lines.join("\n");
99
+ expect(joined).toContain('instance_id="inst-1"');
100
+ });
101
+
102
+ test("--instance-id=<id> form is recognised (#1412)", async () => {
103
+ prevDbUrl = process.env["DATABASE_URL"];
104
+ process.env["DATABASE_URL"] = testUrl;
105
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:eq", "inst-2");
106
+ const { out, lines } = captureOut();
107
+ const code = await runConsumerCli(["status", "test:consumer:eq", "--instance-id=inst-2"], out);
108
+ expect(code).toBe(0);
109
+ expect(lines.join("\n")).toContain('instance_id="inst-2"');
110
+ });
111
+
112
+ test("--instance-id before the name still parses the name correctly (#1412)", async () => {
113
+ prevDbUrl = process.env["DATABASE_URL"];
114
+ process.env["DATABASE_URL"] = testUrl;
115
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:order", "inst-3");
116
+ const { out, lines } = captureOut();
117
+ const code = await runConsumerCli(
118
+ ["status", "--instance-id", "inst-3", "test:consumer:order"],
119
+ out,
120
+ );
121
+ expect(code).toBe(0);
122
+ expect(lines.join("\n")).toContain('instance_id="inst-3"');
123
+ });
124
+
125
+ test("--instance-id with a missing value errors instead of silently falling back to shared (#1412)", async () => {
126
+ prevDbUrl = process.env["DATABASE_URL"];
127
+ process.env["DATABASE_URL"] = testUrl;
128
+ const { out, lines } = captureOut();
129
+ const code = await runConsumerCli(["status", "test:consumer:foo", "--instance-id"], out);
130
+ expect(code).toBe(1);
131
+ expect(lines.join("\n")).toContain("--instance-id braucht einen Wert");
132
+ });
133
+ });
134
+
135
+ describe("runConsumerCli — unknown/empty subcommand exit codes", () => {
136
+ test("no subcommand at all → exit 0 (usage, not an error)", async () => {
137
+ const { out, lines } = captureOut();
138
+ const code = await runConsumerCli([], out);
139
+ expect(code).toBe(0);
140
+ expect(lines.join("\n")).toContain("Subcommands:");
141
+ });
142
+
143
+ test("an unrecognized subcommand → exit 1", async () => {
144
+ const { out, lines } = captureOut();
145
+ const code = await runConsumerCli(["bogus"], out);
146
+ expect(code).toBe(1);
147
+ expect(lines.join("\n")).toContain("Subcommands:");
148
+ });
149
+ });
150
+
151
+ describe("runConsumerCli restart", () => {
152
+ test("dead consumer → idle, exit 0", async () => {
153
+ prevDbUrl = process.env["DATABASE_URL"];
154
+ process.env["DATABASE_URL"] = testUrl;
155
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:bar", SHARED);
156
+ await markConsumerRebuildFailed(testDb.db, "test:consumer:bar", SHARED, "boom");
157
+ const { out, lines } = captureOut();
158
+ const code = await runConsumerCli(["restart", "test:consumer:bar"], out);
159
+ expect(code).toBe(0);
160
+ expect(lines.join("\n")).toContain("→ idle");
161
+ });
162
+
163
+ test("non-dead consumer → propagated error, exit 1", async () => {
164
+ prevDbUrl = process.env["DATABASE_URL"];
165
+ process.env["DATABASE_URL"] = testUrl;
166
+ await insertConsumerIfAbsent(testDb.db, "test:consumer:baz", SHARED);
167
+ const { out, lines } = captureOut();
168
+ const code = await runConsumerCli(["restart", "test:consumer:baz"], out);
169
+ expect(code).toBe(1);
170
+ expect(lines.join("\n")).toContain("not dead");
171
+ });
172
+ });
@@ -0,0 +1,94 @@
1
+ // Issue #912 — Teilbare Entity-Permalinks. Der Copy-Link-Button selbst ist
2
+ // UI-Zucker (baut eine URL, kopiert sie); die eigentliche Anforderung des
3
+ // Issues ist "ein Permalink darf nie Auth/Tenant-Isolation umgehen". Ein
4
+ // entityEdit-Permalink öffnet beim Klick exactly denselben
5
+ // `<feature>:query:<entity>:detail`-Call, den jede Entity schon für ihr
6
+ // Update-Formular nutzt — es gibt keinen separaten Permalink-Resolve-Pfad.
7
+ // Dieser Test beweist den Deny für beide Achsen, die einen Leak verhindern
8
+ // müssten: Rollen-Gate (dispatcher-seitig, default-deny) und Tenant-Scoping
9
+ // (strukturell über ctx.db).
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { setupTestStackFromFeatures } from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
13
+ import {
14
+ createEntity,
15
+ createTextField,
16
+ defineEntityCreateHandler,
17
+ defineEntityDetailHandler,
18
+ defineFeature,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import {
21
+ createTestUser,
22
+ pushEntityProjectionTables,
23
+ testTenantId,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+
26
+ const noteEntity = createEntity({
27
+ table: "read_permalink_notes",
28
+ fields: {
29
+ title: createTextField({ required: true, maxLength: 160, allowPlaintext: "is-business-data" }),
30
+ },
31
+ });
32
+
33
+ const permalinkFeature = defineFeature("permalink-fixture", (r) => {
34
+ r.entity("note", noteEntity);
35
+ const access = { roles: ["TenantAdmin"] } as const;
36
+ r.writeHandler(defineEntityCreateHandler("note", noteEntity, { access }));
37
+ r.queryHandler(defineEntityDetailHandler("note", noteEntity, { access }));
38
+ });
39
+
40
+ describe("entityEdit permalink open-path denies cleanly", () => {
41
+ test("unauthorized role → AccessDeniedError, not the record", async () => {
42
+ const stack = await setupTestStackFromFeatures([permalinkFeature]);
43
+ try {
44
+ const tenantId = testTenantId(701);
45
+ const owner = createTestUser({ id: 701, tenantId, roles: ["TenantAdmin"] });
46
+ const outsider = createTestUser({ id: 702, tenantId, roles: ["TeamMember"] });
47
+ await pushEntityProjectionTables(stack, stack.registry);
48
+
49
+ const created = await stack.http.writeOk<{ id: string }>(
50
+ "permalink-fixture:write:note:create",
51
+ { title: "Q3 Forecast" },
52
+ owner,
53
+ );
54
+
55
+ const res = await stack.http.query(
56
+ "permalink-fixture:query:note:detail",
57
+ { id: created.id },
58
+ outsider,
59
+ );
60
+ expect(res.ok).toBe(false);
61
+ const body = (await res.json()) as { isSuccess?: boolean; error?: { code?: string } };
62
+ expect(body.isSuccess).not.toBe(true);
63
+ expect(body.error?.code).toBe("access_denied");
64
+ } finally {
65
+ await stack.cleanup();
66
+ }
67
+ });
68
+
69
+ test("cross-tenant open → not-found, not the other tenant's record", async () => {
70
+ const stack = await setupTestStackFromFeatures([permalinkFeature]);
71
+ try {
72
+ const tenantA = testTenantId(703);
73
+ const tenantB = testTenantId(704);
74
+ const ownerA = createTestUser({ id: 703, tenantId: tenantA, roles: ["TenantAdmin"] });
75
+ const adminB = createTestUser({ id: 704, tenantId: tenantB, roles: ["TenantAdmin"] });
76
+ await pushEntityProjectionTables(stack, stack.registry);
77
+
78
+ const created = await stack.http.writeOk<{ id: string }>(
79
+ "permalink-fixture:write:note:create",
80
+ { title: "Tenant A Secret" },
81
+ ownerA,
82
+ );
83
+
84
+ const row = await stack.http.queryOk<Record<string, unknown> | null>(
85
+ "permalink-fixture:query:note:detail",
86
+ { id: created.id },
87
+ adminB,
88
+ );
89
+ expect(row).toBeNull();
90
+ } finally {
91
+ await stack.cleanup();
92
+ }
93
+ });
94
+ });
@@ -182,7 +182,7 @@ const userFeature = defineFeature("users", (r) => {
182
182
  { access: { openToAll: true } },
183
183
  );
184
184
 
185
- r.entityHook("postSave", user, async (result) => {
185
+ r.hook("postSave", { allOf: user }, async (result) => {
186
186
  featurePostSaveLog.push(result);
187
187
  });
188
188
 
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
6
+ import * as timeModule from "../time";
7
+
8
+ function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
9
+ const log: string[] = [];
10
+ const err: string[] = [];
11
+ return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
12
+ }
13
+
14
+ describe("runSchemaCli — Temporal polyfill", () => {
15
+ let appCwd: string;
16
+
17
+ afterEach(() => {
18
+ if (appCwd) rmSync(appCwd, { recursive: true, force: true });
19
+ });
20
+
21
+ test("calls ensureTemporalPolyfill on every invocation", async () => {
22
+ // runProdApp/runDevApp call ensureTemporalPolyfill() at boot; the
23
+ // standalone CLI (migrate-db initContainer, `bun kumiko.js schema apply`)
24
+ // never goes through that boot path. Without this, a projection rebuild's
25
+ // tz/timestamp coercion throws "Temporal is not defined" on any runtime
26
+ // that lacks native Temporal — deterministically, since the crashed
27
+ // process still records the migration as applied and the rebuild is
28
+ // never retried on the next run.
29
+ //
30
+ // Asserting on globalThis.Temporal directly doesn't work here: the test
31
+ // harness's own preload (test-setup/base.preload.ts) already calls
32
+ // ensureTemporalPolyfill() once per process, and its idempotency cache
33
+ // (a module-level flag, not re-derived from globalThis) short-circuits
34
+ // any later call regardless of what a test does to globalThis.Temporal in
35
+ // between. Spying on the imported binding sidesteps that cache entirely.
36
+ appCwd = mkdtempSync(join(tmpdir(), "kumiko-schema-cli-temporal-"));
37
+ mkdirSync(join(appCwd, "kumiko"), { recursive: true });
38
+ const spy = spyOn(timeModule, "ensureTemporalPolyfill");
39
+ try {
40
+ const cap = captureOut();
41
+ const code = await runSchemaCli([], appCwd, cap.out);
42
+ expect(code).toBe(0);
43
+ expect(spy).toHaveBeenCalled();
44
+ } finally {
45
+ spy.mockRestore();
46
+ }
47
+ });
48
+ });