@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/src/schema-cli.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  baselineMigrations,
15
15
  createDbConnection,
16
16
  type DbConnection,
17
+ diffReplayAgainstSnapshot,
17
18
  fetchAppliedMigrations,
18
19
  generateMigration,
19
20
  loadMigrationsFromDir,
@@ -21,6 +22,7 @@ import {
21
22
  readRebuildMarker,
22
23
  rebuildTablesFromDiff,
23
24
  type renderTablesDdl,
25
+ replayMigrationsDir,
24
26
  runMigrationsFromDir,
25
27
  tableExists,
26
28
  writeRebuildMarker,
@@ -36,6 +38,7 @@ import {
36
38
  createProjectionStateTable,
37
39
  rebuildProjection,
38
40
  } from "./pipeline";
41
+ import { ensureTemporalPolyfill } from "./time";
39
42
 
40
43
  export type SchemaCliOut = {
41
44
  readonly log: (line: string) => void;
@@ -44,17 +47,28 @@ export type SchemaCliOut = {
44
47
 
45
48
  const SNAPSHOT_FILENAME = ".snapshot.json";
46
49
 
47
- async function loadEntityMetasFromApp(
48
- schemaFile: string,
49
- ): Promise<Parameters<typeof renderTablesDdl>[0]> {
50
+ type SchemaMod = {
51
+ readonly entityMetas: Parameters<typeof renderTablesDdl>[0];
52
+ readonly features?: unknown;
53
+ };
54
+
55
+ // Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
56
+ // both need ENTITY_METAS (and validate additionally wants FEATURES); this is
57
+ // the one place that enforces "ENTITY_METAS must be an array" so the two
58
+ // commands can't drift on the check or its wording. Throws — callers that
59
+ // want a graceful exit code (validate) catch it themselves.
60
+ async function loadSchemaModFromApp(schemaFile: string): Promise<SchemaMod> {
50
61
  // bun imports TS directly — no spawn needed.
51
- const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
62
+ const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown; FEATURES?: unknown };
52
63
  if (!Array.isArray(mod.ENTITY_METAS)) {
53
64
  throw new Error(
54
65
  `Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
55
66
  );
56
67
  }
57
- return mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
68
+ return {
69
+ entityMetas: mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0],
70
+ features: mod.FEATURES,
71
+ };
58
72
  }
59
73
 
60
74
  function nextSequenceNumber(migrationsDir: string): number {
@@ -115,6 +129,13 @@ export async function runSchemaCli(
115
129
  out: SchemaCliOut,
116
130
  options: RunSchemaCliOptions = {},
117
131
  ): Promise<number> {
132
+ // runProdApp/runDevApp install this at boot; the standalone CLI (the
133
+ // migrate-db initContainer, `bun kumiko.js schema apply`) never goes through
134
+ // that boot path, so a projection rebuild's tz/timestamp coercion throws
135
+ // "Temporal is not defined" — deterministically, on every rebuild-marker
136
+ // migration, since the crashed process still records the migration as
137
+ // applied and the rebuild is never retried.
138
+ await ensureTemporalPolyfill();
118
139
  const sub = argv[0];
119
140
  const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
120
141
  const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
@@ -132,7 +153,7 @@ export async function runSchemaCli(
132
153
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
133
154
  return 1;
134
155
  }
135
- const metas = await loadEntityMetasFromApp(schemaFile);
156
+ const { entityMetas: metas } = await loadSchemaModFromApp(schemaFile);
136
157
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
137
158
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
138
159
  const result = generateMigration({
@@ -179,11 +200,18 @@ export async function runSchemaCli(
179
200
 
180
201
  case "validate": {
181
202
  // Static, DB-free boot-blocking checks for CI — catches "this won't boot"
182
- // before deploy. Two layers, no database:
203
+ // before deploy. Three layers, no database:
183
204
  // 1. schema drift: would `generate` write a migration? (= an entity was
184
205
  // added/changed but never generated → missing table → prod 500)
185
206
  // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
186
207
  // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
208
+ // 3. migration-content drift: replay every committed *.sql file and
209
+ // diff the result against .snapshot.json — catches a migration
210
+ // file whose SQL body doesn't match what its own snapshot entry
211
+ // claims (e.g. an accidental copy-paste from an earlier file).
212
+ // Layer 1 alone misses this: it only compares ENTITY_METAS to the
213
+ // snapshot, never the snapshot to the SQL that's supposed to have
214
+ // produced it.
187
215
  // The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
188
216
  if (!existsSync(schemaFile)) {
189
217
  out.err(` ${schemaFile} fehlt.`);
@@ -191,20 +219,18 @@ export async function runSchemaCli(
191
219
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
192
220
  return 1;
193
221
  }
194
- const mod = (await import(schemaFile)) as {
195
- ENTITY_METAS?: unknown;
196
- FEATURES?: unknown;
197
- };
198
- if (!Array.isArray(mod.ENTITY_METAS)) {
199
- out.err(
200
- ` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
201
- );
222
+ let schemaMod: SchemaMod;
223
+ try {
224
+ schemaMod = await loadSchemaModFromApp(schemaFile);
225
+ } catch (e) {
226
+ out.err(` ${e instanceof Error ? e.message : String(e)}`);
202
227
  return 1;
203
228
  }
229
+ const mod = { FEATURES: schemaMod.features };
204
230
  let ok = true;
205
231
 
206
232
  // 1. Schema drift — compute the diff, never write.
207
- const metas = mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
233
+ const metas = schemaMod.entityMetas;
208
234
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
209
235
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
210
236
  const drift = generateMigration({
@@ -245,6 +271,40 @@ export async function runSchemaCli(
245
271
  );
246
272
  }
247
273
 
274
+ // 3. Migration-content drift — replay the committed *.sql files and
275
+ // diff the reconstructed schema against .snapshot.json.
276
+ const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
277
+ if (existsSync(migrationsDir) && committedSnapshot !== null) {
278
+ try {
279
+ const replayed = replayMigrationsDir(migrationsDir);
280
+ const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
281
+ if (mismatches.length === 0) {
282
+ out.log(" ✓ migrations: table/column names match .snapshot.json");
283
+ } else {
284
+ ok = false;
285
+ out.err(
286
+ " ✗ migration-content drift: committed *.sql files don't produce .snapshot.json.",
287
+ );
288
+ for (const m of mismatches) {
289
+ out.err(` ${m.tableName} (${m.kind}): ${m.detail}`);
290
+ }
291
+ out.err(
292
+ " Fix: a migration file's body doesn't match what it (or the snapshot) claims — hand-fix the file, or ship a corrective migration if it's already applied in prod.",
293
+ );
294
+ }
295
+ } catch (e) {
296
+ // replayMigrationsDir fail-loud's on a table-DDL statement it can't
297
+ // parse — that must surface as a normal ✗ line like every other
298
+ // check here, not a raw stack trace that kills the process before
299
+ // any later check (or the Fix: help) ever runs (framework#1535).
300
+ ok = false;
301
+ out.err(` ✗ migration-content: ${e instanceof Error ? e.message : String(e)}`);
302
+ out.err(
303
+ " Fix: a statement in a migration file isn't recognizable to the replay parser — extend the parser's recognized patterns, or reword the migration to one it understands.",
304
+ );
305
+ }
306
+ }
307
+
248
308
  return ok ? 0 : 1;
249
309
  }
250
310
 
@@ -1,13 +1,37 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { generateId as uuid } from "@cosmicdrift/kumiko-framework/utils";
4
3
  import { Meilisearch } from "meilisearch";
5
- import { createMeilisearchAdapter } from "../meilisearch-adapter";
4
+ import { createMeilisearchAdapter, meilisearchTenantIndex } from "../meilisearch-adapter";
6
5
  import type { SearchAdapter } from "../types";
7
6
 
8
7
  const MEILI_URL = process.env["MEILI_URL"] ?? "http://localhost:17700";
9
8
  const MEILI_KEY = process.env["MEILI_MASTER_KEY"] ?? "kumiko-dev-key";
10
9
 
10
+ async function meiliAvailable(): Promise<boolean> {
11
+ try {
12
+ const health = await fetch(`${MEILI_URL}/health`, { signal: AbortSignal.timeout(2000) });
13
+ return health.ok;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ // skipIf when compose Meili is down — unit tests still cover index/doc id shaping.
20
+ // In a dedicated integration job (REQUIRE_MEILI=1) a down Meili is a hard
21
+ // failure, not a silent skip — the compose service is expected to be up there.
22
+ const MEILI_UP = await meiliAvailable();
23
+ if (!MEILI_UP) {
24
+ if (process.env["REQUIRE_MEILI"] === "1") {
25
+ throw new Error(
26
+ `REQUIRE_MEILI=1 but Meilisearch not reachable at ${MEILI_URL} — integration job misconfigured`,
27
+ );
28
+ }
29
+ console.warn(
30
+ `Meilisearch not reachable at ${MEILI_URL} — skipping live adapter tests ` +
31
+ `(docker compose up -d meilisearch, default port 17700)`,
32
+ );
33
+ }
34
+
11
35
  // Use a fake tenantId to get a unique index name
12
36
  const TENANT = uuid();
13
37
 
@@ -15,222 +39,220 @@ let adapter: SearchAdapter;
15
39
  let client: Meilisearch;
16
40
  let indexPrefix: string;
17
41
 
18
- // Mirrors meilisearch-adapter.ts's tenantIndex() used by tests that need
19
- // to talk to Meilisearch directly (e.g. stats before/after a no-op).
20
- const tenantIndex = (prefix: string, tenantId: TenantId): string => `${prefix}t${tenantId}`;
21
-
22
- beforeAll(async () => {
23
- client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
24
- indexPrefix = `test_${uuid().slice(-6)}_`;
25
- adapter = createMeilisearchAdapter({
26
- url: MEILI_URL,
27
- apiKey: MEILI_KEY,
28
- indexPrefix,
29
- });
42
+ describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
43
+ beforeAll(async () => {
44
+ client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
45
+ indexPrefix = `test_${uuid().slice(-6)}_`;
46
+ adapter = createMeilisearchAdapter({
47
+ url: MEILI_URL,
48
+ apiKey: MEILI_KEY,
49
+ indexPrefix,
50
+ });
30
51
 
31
- await adapter.configure(TENANT, {
32
- searchableFields: ["email", "firstName", "lastName", "notes", "_roles"],
33
- rankingFields: ["email", "firstName", "lastName", "notes", "_roles"],
34
- });
52
+ await adapter.configure(TENANT, {
53
+ searchableFields: ["email", "firstName", "lastName", "notes", "_roles"],
54
+ rankingFields: ["email", "firstName", "lastName", "notes", "_roles"],
55
+ });
35
56
 
36
- // Seed data with different entity types and weights
37
- await adapter.index(TENANT, {
38
- entityType: "user",
39
- entityId: 1,
40
- weight: 10,
41
- fields: {
42
- email: "marc.weber@company.de",
43
- firstName: "Marc",
44
- lastName: "Weber",
45
- notes: "Senior developer",
46
- _roles: "Admin, Developer",
47
- },
48
- });
49
- await adapter.index(TENANT, {
50
- entityType: "user",
51
- entityId: 2,
52
- weight: 10,
53
- fields: {
54
- email: "anna.schmidt@company.de",
55
- firstName: "Anna",
56
- lastName: "Schmidt",
57
- notes: "Project manager",
58
- },
59
- });
60
- await adapter.index(TENANT, {
61
- entityType: "user",
62
- entityId: 3,
63
- weight: 10,
64
- fields: {
65
- email: "admin@company.de",
66
- firstName: "Admin",
67
- lastName: "User",
68
- notes: "System administrator",
69
- },
70
- });
71
- await adapter.index(TENANT, {
72
- entityType: "role",
73
- entityId: 1,
74
- weight: 1,
75
- fields: { firstName: "Admin" },
76
- });
77
- await adapter.index(TENANT, {
78
- entityType: "role",
79
- entityId: 2,
80
- weight: 1,
81
- fields: { firstName: "Developer" },
82
- });
83
- await adapter.index(TENANT, {
84
- entityType: "department",
85
- entityId: 1,
86
- weight: 5,
87
- fields: { firstName: "Engineering" },
57
+ // Seed data with different entity types and weights
58
+ await adapter.index(TENANT, {
59
+ entityType: "user",
60
+ entityId: 1,
61
+ weight: 10,
62
+ fields: {
63
+ email: "marc.weber@company.de",
64
+ firstName: "Marc",
65
+ lastName: "Weber",
66
+ notes: "Senior developer",
67
+ _roles: "Admin, Developer",
68
+ },
69
+ });
70
+ await adapter.index(TENANT, {
71
+ entityType: "user",
72
+ entityId: 2,
73
+ weight: 10,
74
+ fields: {
75
+ email: "anna.schmidt@company.de",
76
+ firstName: "Anna",
77
+ lastName: "Schmidt",
78
+ notes: "Project manager",
79
+ },
80
+ });
81
+ await adapter.index(TENANT, {
82
+ entityType: "user",
83
+ entityId: 3,
84
+ weight: 10,
85
+ fields: {
86
+ email: "admin@company.de",
87
+ firstName: "Admin",
88
+ lastName: "User",
89
+ notes: "System administrator",
90
+ },
91
+ });
92
+ await adapter.index(TENANT, {
93
+ entityType: "role",
94
+ entityId: 1,
95
+ weight: 1,
96
+ fields: { firstName: "Admin" },
97
+ });
98
+ await adapter.index(TENANT, {
99
+ entityType: "role",
100
+ entityId: 2,
101
+ weight: 1,
102
+ fields: { firstName: "Developer" },
103
+ });
104
+ await adapter.index(TENANT, {
105
+ entityType: "department",
106
+ entityId: 1,
107
+ weight: 5,
108
+ fields: { firstName: "Engineering" },
109
+ });
88
110
  });
89
- });
90
111
 
91
- afterAll(async () => {
92
- // Clean up all test indices
93
- const indices = await client.getIndexes();
94
- for (const idx of indices.results) {
95
- if (idx.uid.startsWith("test_")) {
96
- try {
97
- await client.index(idx.uid).delete().waitTask();
98
- } catch {
99
- /* ok */
112
+ afterAll(async () => {
113
+ // Clean up all test indices
114
+ const indices = await client.getIndexes();
115
+ for (const idx of indices.results) {
116
+ if (idx.uid.startsWith("test_")) {
117
+ try {
118
+ await client.index(idx.uid).delete().waitTask();
119
+ } catch {
120
+ /* ok */
121
+ }
100
122
  }
101
123
  }
102
- }
103
- });
124
+ });
104
125
 
105
- // --- Basic search ---
126
+ // --- Basic search ---
106
127
 
107
- describe("basic search", () => {
108
- test("finds user by name", async () => {
109
- const results = await adapter.search(TENANT, "anna");
110
- expect(results.some((r) => r.entityId === 2 && r.entityType === "user")).toBe(true);
111
- });
128
+ describe("basic search", () => {
129
+ test("finds user by name", async () => {
130
+ const results = await adapter.search(TENANT, "anna");
131
+ expect(results.some((r) => r.entityId === 2 && r.entityType === "user")).toBe(true);
132
+ });
112
133
 
113
- test("returns empty for no match", async () => {
114
- const results = await adapter.search(TENANT, "zzzznonexistent99999");
115
- expect(results).toEqual([]);
134
+ test("returns empty for no match", async () => {
135
+ const results = await adapter.search(TENANT, "zzzznonexistent99999");
136
+ expect(results).toEqual([]);
137
+ });
116
138
  });
117
- });
118
139
 
119
- // --- Partial matching ---
140
+ // --- Partial matching ---
120
141
 
121
- describe("partial matching", () => {
122
- test("finds by prefix", async () => {
123
- const results = await adapter.search(TENANT, "mar");
124
- expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
142
+ describe("partial matching", () => {
143
+ test("finds by prefix", async () => {
144
+ const results = await adapter.search(TENANT, "mar");
145
+ expect(results.some((r) => r.entityId === 1 && r.entityType === "user")).toBe(true);
146
+ });
125
147
  });
126
- });
127
148
 
128
- // --- Typo tolerance ---
149
+ // --- Typo tolerance ---
129
150
 
130
- describe("typo tolerance", () => {
131
- test("finds despite typos", async () => {
132
- const results = await adapter.search(TENANT, "schmit");
133
- expect(results.some((r) => r.entityId === 2)).toBe(true);
151
+ describe("typo tolerance", () => {
152
+ test("finds despite typos", async () => {
153
+ const results = await adapter.search(TENANT, "schmit");
154
+ expect(results.some((r) => r.entityId === 2)).toBe(true);
155
+ });
134
156
  });
135
- });
136
157
 
137
- // --- Filter by entity type (list search) ---
158
+ // --- Filter by entity type (list search) ---
138
159
 
139
- describe("list search (filterType)", () => {
140
- test("only returns specified entity type", async () => {
141
- const results = await adapter.search(TENANT, "admin", { filterType: "user" });
142
- expect(results.every((r) => r.entityType === "user")).toBe(true);
143
- expect(results.length).toBeGreaterThan(0);
144
- });
160
+ describe("list search (filterType)", () => {
161
+ test("only returns specified entity type", async () => {
162
+ const results = await adapter.search(TENANT, "admin", { filterType: "user" });
163
+ expect(results.every((r) => r.entityType === "user")).toBe(true);
164
+ expect(results.length).toBeGreaterThan(0);
165
+ });
145
166
 
146
- test("role filter returns only roles", async () => {
147
- const results = await adapter.search(TENANT, "admin", { filterType: "role" });
148
- expect(results.every((r) => r.entityType === "role")).toBe(true);
167
+ test("role filter returns only roles", async () => {
168
+ const results = await adapter.search(TENANT, "admin", { filterType: "role" });
169
+ expect(results.every((r) => r.entityType === "role")).toBe(true);
170
+ });
149
171
  });
150
- });
151
172
 
152
- // --- Global search with weight ---
173
+ // --- Global search with weight ---
153
174
 
154
- describe("global search with searchWeight", () => {
155
- test("user (weight 10) ranks before role (weight 1) for same query", async () => {
156
- const results = await adapter.search(TENANT, "admin");
157
- const userIdx = results.findIndex((r) => r.entityType === "user");
158
- const roleIdx = results.findIndex((r) => r.entityType === "role");
159
- // User should appear before Role due to _weight:desc sort
160
- if (userIdx >= 0 && roleIdx >= 0) {
161
- expect(userIdx).toBeLessThan(roleIdx);
162
- }
175
+ describe("global search with searchWeight", () => {
176
+ test("user (weight 10) ranks before role (weight 1) for same query", async () => {
177
+ const results = await adapter.search(TENANT, "admin");
178
+ const userIdx = results.findIndex((r) => r.entityType === "user");
179
+ const roleIdx = results.findIndex((r) => r.entityType === "role");
180
+ // User should appear before Role due to _weight:desc sort
181
+ if (userIdx >= 0 && roleIdx >= 0) {
182
+ expect(userIdx).toBeLessThan(roleIdx);
183
+ }
184
+ });
163
185
  });
164
- });
165
186
 
166
- // --- Resolved relation data ---
187
+ // --- Resolved relation data ---
167
188
 
168
- describe("relation data in search", () => {
169
- test("finds user by role name in _roles field", async () => {
170
- const results = await adapter.search(TENANT, "developer", { filterType: "user" });
171
- expect(results.some((r) => r.entityId === 1)).toBe(true);
189
+ describe("relation data in search", () => {
190
+ test("finds user by role name in _roles field", async () => {
191
+ const results = await adapter.search(TENANT, "developer", { filterType: "user" });
192
+ expect(results.some((r) => r.entityId === 1)).toBe(true);
193
+ });
172
194
  });
173
- });
174
195
 
175
- // --- Remove ---
196
+ // --- Remove ---
176
197
 
177
- describe("remove", () => {
178
- test("removed document not found", async () => {
179
- // Create temp doc
180
- await adapter.index(TENANT, {
181
- entityType: "temp",
182
- entityId: 999,
183
- weight: 1,
184
- fields: { firstName: "DeleteMe" },
185
- });
186
- let results = await adapter.search(TENANT, "deleteme");
187
- expect(results.some((r) => r.entityId === 999)).toBe(true);
198
+ describe("remove", () => {
199
+ test("removed document not found", async () => {
200
+ // Create temp doc
201
+ await adapter.index(TENANT, {
202
+ entityType: "temp",
203
+ entityId: 999,
204
+ weight: 1,
205
+ fields: { firstName: "DeleteMe" },
206
+ });
207
+ let results = await adapter.search(TENANT, "deleteme");
208
+ expect(results.some((r) => r.entityId === 999)).toBe(true);
188
209
 
189
- await adapter.remove(TENANT, "temp", 999);
190
- results = await adapter.search(TENANT, "deleteme");
191
- expect(results.some((r) => r.entityId === 999)).toBe(false);
210
+ await adapter.remove(TENANT, "temp", 999);
211
+ results = await adapter.search(TENANT, "deleteme");
212
+ expect(results.some((r) => r.entityId === 999)).toBe(false);
213
+ });
192
214
  });
193
- });
194
215
 
195
- // --- Batch variants ---
216
+ // --- Batch variants ---
196
217
 
197
- describe("indexBatch / removeBatch", () => {
198
- test("indexBatch indexes multiple docs in a single task", async () => {
199
- const docs = Array.from({ length: 5 }, (_, i) => ({
200
- entityType: "batch" as const,
201
- entityId: 1000 + i,
202
- weight: 1,
203
- fields: { firstName: `Bulk${i}`, notes: "batchtoken" },
204
- }));
205
- await adapter.indexBatch?.(TENANT, docs);
206
-
207
- const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
208
- expect(hits.length).toBe(5);
209
- const ids = hits.map((h) => h.entityId).sort();
210
- expect(ids).toEqual([1000, 1001, 1002, 1003, 1004]);
211
- });
218
+ describe("indexBatch / removeBatch", () => {
219
+ test("indexBatch indexes multiple docs in a single task", async () => {
220
+ const docs = Array.from({ length: 5 }, (_, i) => ({
221
+ entityType: "batch" as const,
222
+ entityId: 1000 + i,
223
+ weight: 1,
224
+ fields: { firstName: `Bulk${i}`, notes: "batchtoken" },
225
+ }));
226
+ await adapter.indexBatch?.(TENANT, docs);
212
227
 
213
- test("removeBatch removes multiple docs in a single task", async () => {
214
- await adapter.removeBatch?.(
215
- TENANT,
216
- [1000, 1001, 1002, 1003, 1004].map((id) => ({ entityType: "batch", entityId: id })),
217
- );
218
- const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
219
- expect(hits.length).toBe(0);
220
- });
228
+ const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
229
+ expect(hits.length).toBe(5);
230
+ const ids = hits.map((h) => h.entityId).sort();
231
+ expect(ids).toEqual([1000, 1001, 1002, 1003, 1004]);
232
+ });
221
233
 
222
- test("indexBatch no-ops on empty array no Meilisearch task created", async () => {
223
- // We verify the "no-op" contract by peeking at Meilisearch's own
224
- // IndexStats.numberOfDocuments + isIndexing directly. If the adapter had
225
- // accidentally sent an addDocuments request (even with an empty body),
226
- // isIndexing would flip to true or a task would land in the queue.
227
- // numberOfDocuments must also stay unchanged the empty batch must not
228
- // replace, delete, or otherwise touch existing docs.
229
- const index = client.index(tenantIndex(indexPrefix, TENANT));
230
- const before = await index.getStats();
231
- await expect(adapter.indexBatch?.(TENANT, [])).resolves.toBeUndefined();
232
- const after = await index.getStats();
233
- expect(after.numberOfDocuments).toBe(before.numberOfDocuments);
234
- expect(after.isIndexing).toBe(false);
234
+ test("removeBatch removes multiple docs in a single task", async () => {
235
+ await adapter.removeBatch?.(
236
+ TENANT,
237
+ [1000, 1001, 1002, 1003, 1004].map((id) => ({ entityType: "batch", entityId: id })),
238
+ );
239
+ const hits = await adapter.search(TENANT, "batchtoken", { limit: 20, filterType: "batch" });
240
+ expect(hits.length).toBe(0);
241
+ });
242
+
243
+ test("indexBatch no-ops on empty array — no Meilisearch task created", async () => {
244
+ // We verify the "no-op" contract by peeking at Meilisearch's own
245
+ // IndexStats.numberOfDocuments + isIndexing directly. If the adapter had
246
+ // accidentally sent an addDocuments request (even with an empty body),
247
+ // isIndexing would flip to true or a task would land in the queue.
248
+ // numberOfDocuments must also stay unchanged — the empty batch must not
249
+ // replace, delete, or otherwise touch existing docs.
250
+ const index = client.index(meilisearchTenantIndex(indexPrefix, TENANT));
251
+ const before = await index.getStats();
252
+ await expect(adapter.indexBatch?.(TENANT, [])).resolves.toBeUndefined();
253
+ const after = await index.getStats();
254
+ expect(after.numberOfDocuments).toBe(before.numberOfDocuments);
255
+ expect(after.isIndexing).toBe(false);
256
+ });
235
257
  });
236
258
  });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { meilisearchDocId, meilisearchTenantIndex } from "../meilisearch-adapter";
3
+
4
+ describe("meilisearchTenantIndex", () => {
5
+ test("prefixes tenant id with t after the index prefix", () => {
6
+ expect(meilisearchTenantIndex("kumiko_", "00000000-0000-4000-8000-000000000001")).toBe(
7
+ "kumiko_t00000000-0000-4000-8000-000000000001",
8
+ );
9
+ });
10
+
11
+ test("keeps custom prefixes intact", () => {
12
+ expect(meilisearchTenantIndex("test_abc_", 42 as never)).toBe("test_abc_t42");
13
+ });
14
+ });
15
+
16
+ describe("meilisearchDocId", () => {
17
+ test("joins entity type and id with underscore", () => {
18
+ expect(meilisearchDocId("user", 7)).toBe("user_7");
19
+ });
20
+
21
+ test("preserves UUID dashes (legal Meilisearch primary-key chars)", () => {
22
+ expect(meilisearchDocId("order", "a1b2c3d4-e5f6-7890-abcd-ef1234567890" as never)).toBe(
23
+ "order_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
24
+ );
25
+ });
26
+
27
+ test("sanitizes illegal primary-key characters to underscore", () => {
28
+ expect(meilisearchDocId("note", "a:b/c.d" as never)).toBe("note_a_b_c_d");
29
+ });
30
+ });