@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
@@ -11,8 +11,10 @@
11
11
  // Designer/AI know the call exists.
12
12
 
13
13
  import { describe, expect, test } from "bun:test";
14
+ import { resolve } from "node:path";
14
15
  import { Project } from "ts-morph";
15
- import { parseSourceFile } from "../parse";
16
+ import { isRawRefSentinel, readDataLiteralNode } from "../extractors/shared";
17
+ import { parseFeatureFile, parseSourceFile } from "../parse";
16
18
 
17
19
  function createProject() {
18
20
  return new Project({
@@ -122,6 +124,102 @@ defineFeature("isolated", (r) => {
122
124
  expect(result.featureName).toBe("nameOnly");
123
125
  expect(result.patterns).toEqual([]);
124
126
  });
127
+
128
+ test("recurses into a locally declared registrar-wrapper function", () => {
129
+ const result = parseInline(`
130
+ function registerTaskScreens(registrar) {
131
+ registrar.entity("task", { fields: {} });
132
+ registrar.requires("auth");
133
+ }
134
+ defineFeature("wrapped", (r) => {
135
+ r.systemScope();
136
+ registerTaskScreens(r);
137
+ });
138
+ `);
139
+
140
+ expect(result.errors).toEqual([]);
141
+ expect(result.patterns.map((p) => p.kind)).toEqual(["systemScope", "entity", "requires"]);
142
+ });
143
+
144
+ test("recurses into a registrar-wrapper declared as a const arrow function", () => {
145
+ const result = parseInline(`
146
+ const registerTaskScreens = (registrar) => {
147
+ registrar.entity("task", { fields: {} });
148
+ };
149
+ defineFeature("wrapped", (r) => {
150
+ registerTaskScreens(r);
151
+ });
152
+ `);
153
+
154
+ expect(result.errors).toEqual([]);
155
+ expect(result.patterns).toMatchObject([{ kind: "entity", entityName: "task" }]);
156
+ });
157
+
158
+ test("ignores a bare call that does not receive the registrar as an argument", () => {
159
+ const result = parseInline(`
160
+ function unrelated() {
161
+ return 1;
162
+ }
163
+ defineFeature("f", (r) => {
164
+ unrelated();
165
+ r.systemScope();
166
+ });
167
+ `);
168
+
169
+ expect(result.errors).toEqual([]);
170
+ expect(result.patterns).toMatchObject([{ kind: "systemScope" }]);
171
+ });
172
+
173
+ test("does not infinite-loop on a registrar-wrapper cycle", () => {
174
+ const result = parseInline(`
175
+ function a(registrar) {
176
+ registrar.systemScope();
177
+ b(registrar);
178
+ }
179
+ function b(registrar) {
180
+ a(registrar);
181
+ }
182
+ defineFeature("f", (r) => {
183
+ a(r);
184
+ });
185
+ `);
186
+
187
+ expect(result.errors).toEqual([]);
188
+ expect(result.patterns).toMatchObject([{ kind: "systemScope" }]);
189
+ });
190
+ });
191
+
192
+ describe("readDataLiteralNode — raw-ref sentinel", () => {
193
+ function readExpression(source: string) {
194
+ const project = createProject();
195
+ const sourceFile = project.createSourceFile("inline.ts", `const __probe__ = (${source});`);
196
+ const decl = sourceFile.getVariableDeclarationOrThrow("__probe__");
197
+ return readDataLiteralNode(decl.getInitializerOrThrow());
198
+ }
199
+
200
+ test("Identifier reference resolves to a raw-ref sentinel with the exact source text", () => {
201
+ const value = readExpression("eventEntity");
202
+ expect(isRawRefSentinel(value)).toBe(true);
203
+ expect(value).toEqual({ __raw: "eventEntity" });
204
+ });
205
+
206
+ test("zero-arg call expression resolves to a raw-ref sentinel with the exact source text", () => {
207
+ const value = readExpression("createInviteBrandingQuery()");
208
+ expect(isRawRefSentinel(value)).toBe(true);
209
+ expect(value).toEqual({ __raw: "createInviteBrandingQuery()" });
210
+ });
211
+
212
+ test("member access resolves to a raw-ref sentinel", () => {
213
+ const value = readExpression("config.timeout");
214
+ expect(isRawRefSentinel(value)).toBe(true);
215
+ expect(value).toEqual({ __raw: "config.timeout" });
216
+ });
217
+
218
+ test("a single unresolvable property no longer bubbles the whole object to undefined", () => {
219
+ const value = readExpression("{ timeout: 30, query: createQuery() }");
220
+ expect(isRawRefSentinel(value)).toBe(false);
221
+ expect(value).toEqual({ timeout: 30, query: { __raw: "createQuery()" } });
222
+ });
125
223
  });
126
224
 
127
225
  // =============================================================================
@@ -143,12 +241,26 @@ defineFeature("f", (r) => {
143
241
  expect(result.errors).toEqual([]);
144
242
  });
145
243
 
146
- test("emits a ParseError when an argument is not a string literal", () => {
244
+ test("resolves a local const identifier arg via the raw-ref sentinel (#1009)", () => {
147
245
  const result = parseInline(`
148
246
  const dep = "auth";
149
247
  defineFeature("f", (r) => {
150
248
  r.requires(dep);
151
249
  });
250
+ `);
251
+
252
+ expect(result.patterns[0]).toMatchObject({
253
+ kind: "requires",
254
+ featureNames: [{ __raw: "dep" }],
255
+ });
256
+ expect(result.errors).toEqual([]);
257
+ });
258
+
259
+ test("emits a ParseError when an argument cannot be resolved at all", () => {
260
+ const result = parseInline(`
261
+ defineFeature("f", (r) => {
262
+ r.requires(5);
263
+ });
152
264
  `);
153
265
 
154
266
  expect(result.patterns).toEqual([]);
@@ -170,6 +282,21 @@ defineFeature("f", (r) => {
170
282
  featureNames: ["billing"],
171
283
  });
172
284
  });
285
+
286
+ test("keeps a computed feature-name arg as a raw-ref sentinel instead of failing (#1009)", () => {
287
+ const result = parseInline(`
288
+ const someFeature = { name: "billing" };
289
+ defineFeature("f", (r) => {
290
+ r.optionalRequires(someFeature.name);
291
+ });
292
+ `);
293
+
294
+ expect(result.patterns[0]).toMatchObject({
295
+ kind: "optionalRequires",
296
+ featureNames: [{ __raw: "someFeature.name" }],
297
+ });
298
+ expect(result.errors).toEqual([]);
299
+ });
173
300
  });
174
301
 
175
302
  describe("extractReadsConfig", () => {
@@ -185,6 +312,21 @@ defineFeature("f", (r) => {
185
312
  qualifiedKeys: ["auth:config:jwt-ttl", "tenant:config:locale"],
186
313
  });
187
314
  });
315
+
316
+ test("keeps a computed key arg as a raw-ref sentinel instead of failing (#1009)", () => {
317
+ const result = parseInline(`
318
+ const cfg = { key: "auth:config:jwt-ttl" };
319
+ defineFeature("f", (r) => {
320
+ r.readsConfig(cfg.key, "tenant:config:locale");
321
+ });
322
+ `);
323
+
324
+ expect(result.patterns[0]).toMatchObject({
325
+ kind: "readsConfig",
326
+ qualifiedKeys: [{ __raw: "cfg.key" }, "tenant:config:locale"],
327
+ });
328
+ expect(result.errors).toEqual([]);
329
+ });
188
330
  });
189
331
 
190
332
  describe("extractSystemScope", () => {
@@ -269,6 +411,40 @@ defineFeature("f", (r) => {
269
411
  expect(result.errors).toEqual([]);
270
412
  });
271
413
 
414
+ test("keeps an unresolved const reference as a raw-ref sentinel instead of erroring", () => {
415
+ const result = parseInline(`
416
+ const eventEntity = { fields: { name: { type: "text" } } };
417
+ defineFeature("f", (r) => {
418
+ r.entity("event", eventEntity);
419
+ });
420
+ `);
421
+
422
+ expect(result.errors).toEqual([]);
423
+ expect(result.patterns[0]).toMatchObject({
424
+ kind: "entity",
425
+ entityName: "event",
426
+ definition: { __raw: "eventEntity" },
427
+ });
428
+ });
429
+
430
+ test("keeps a factory call reference inside a nested property as a raw-ref sentinel", () => {
431
+ const result = parseInline(`
432
+ function buildFields() {
433
+ return { name: { type: "text" } };
434
+ }
435
+ defineFeature("f", (r) => {
436
+ r.entity("task", { fields: buildFields() });
437
+ });
438
+ `);
439
+
440
+ expect(result.errors).toEqual([]);
441
+ expect(result.patterns[0]).toMatchObject({
442
+ kind: "entity",
443
+ entityName: "task",
444
+ definition: { fields: { __raw: "buildFields()" } },
445
+ });
446
+ });
447
+
272
448
  test("walks through `as const` and `satisfies` wrappers", () => {
273
449
  const result = parseInline(`
274
450
  defineFeature("f", (r) => {
@@ -303,6 +479,60 @@ const ENTITY = "task";
303
479
  defineFeature("f", (r) => {
304
480
  r.entity(ENTITY, { fields: {} });
305
481
  });
482
+ `);
483
+
484
+ expect(result.errors[0]?.methodName).toBe("entity");
485
+ });
486
+
487
+ test("object form captures name + definition", () => {
488
+ const result = parseInline(`
489
+ defineFeature("f", (r) => {
490
+ r.entity({ name: "task", fields: { title: { type: "text" } } });
491
+ });
492
+ `);
493
+
494
+ expect(result.patterns[0]).toMatchObject({
495
+ kind: "entity",
496
+ entityName: "task",
497
+ definition: { fields: { title: { type: "text" } } },
498
+ });
499
+ });
500
+
501
+ test("emits ParseError when called with no arguments", () => {
502
+ const result = parseInline(`
503
+ defineFeature("f", (r) => {
504
+ r.entity();
505
+ });
506
+ `);
507
+
508
+ expect(result.errors[0]?.methodName).toBe("entity");
509
+ });
510
+
511
+ test("emits ParseError when object form is missing the name property", () => {
512
+ const result = parseInline(`
513
+ defineFeature("f", (r) => {
514
+ r.entity({ fields: {} });
515
+ });
516
+ `);
517
+
518
+ expect(result.errors[0]?.methodName).toBe("entity");
519
+ });
520
+
521
+ test("emits ParseError when object form definition contains a function value", () => {
522
+ const result = parseInline(`
523
+ defineFeature("f", (r) => {
524
+ r.entity({ name: "task", fields: { title: { type: "text", default: () => "x" } } });
525
+ });
526
+ `);
527
+
528
+ expect(result.errors[0]?.methodName).toBe("entity");
529
+ });
530
+
531
+ test("emits ParseError when the positional definition argument is missing", () => {
532
+ const result = parseInline(`
533
+ defineFeature("f", (r) => {
534
+ r.entity("task");
535
+ });
306
536
  `);
307
537
 
308
538
  expect(result.errors[0]?.methodName).toBe("entity");
@@ -345,6 +575,71 @@ const taskRef = { name: "task" };
345
575
  defineFeature("f", (r) => {
346
576
  r.relation(taskRef, "owner", { kind: "belongsTo", to: "user" });
347
577
  });
578
+ `);
579
+
580
+ expect(result.errors[0]?.methodName).toBe("relation");
581
+ });
582
+
583
+ test("object form captures entity, name and definition", () => {
584
+ const result = parseInline(`
585
+ defineFeature("f", (r) => {
586
+ r.relation({ entity: "task", name: "owner", kind: "belongsTo", to: "user" });
587
+ });
588
+ `);
589
+
590
+ expect(result.patterns[0]).toMatchObject({
591
+ kind: "relation",
592
+ entityName: "task",
593
+ relationName: "owner",
594
+ definition: { kind: "belongsTo", to: "user" },
595
+ });
596
+ });
597
+
598
+ test("emits ParseError when called with no arguments", () => {
599
+ const result = parseInline(`
600
+ defineFeature("f", (r) => {
601
+ r.relation();
602
+ });
603
+ `);
604
+
605
+ expect(result.errors[0]?.methodName).toBe("relation");
606
+ });
607
+
608
+ test("emits ParseError when object form is missing the entity property", () => {
609
+ const result = parseInline(`
610
+ defineFeature("f", (r) => {
611
+ r.relation({ name: "owner", kind: "belongsTo" });
612
+ });
613
+ `);
614
+
615
+ expect(result.errors[0]?.methodName).toBe("relation");
616
+ });
617
+
618
+ test("emits ParseError when object form is missing the name property", () => {
619
+ const result = parseInline(`
620
+ defineFeature("f", (r) => {
621
+ r.relation({ entity: "task", kind: "belongsTo" });
622
+ });
623
+ `);
624
+
625
+ expect(result.errors[0]?.methodName).toBe("relation");
626
+ });
627
+
628
+ test("emits ParseError when the relation name is not a string literal", () => {
629
+ const result = parseInline(`
630
+ defineFeature("f", (r) => {
631
+ r.relation("task", REL, { kind: "belongsTo" });
632
+ });
633
+ `);
634
+
635
+ expect(result.errors[0]?.methodName).toBe("relation");
636
+ });
637
+
638
+ test("emits ParseError when the positional definition argument is missing", () => {
639
+ const result = parseInline(`
640
+ defineFeature("f", (r) => {
641
+ r.relation("task", "owner");
642
+ });
348
643
  `);
349
644
 
350
645
  expect(result.errors[0]?.methodName).toBe("relation");
@@ -364,6 +659,26 @@ defineFeature("f", (r) => {
364
659
  definition: { id: "tasks", label: "Tasks", screen: "tasks:screen:list" },
365
660
  });
366
661
  });
662
+
663
+ test("emits ParseError when called with no arguments", () => {
664
+ const result = parseInline(`
665
+ defineFeature("f", (r) => {
666
+ r.nav();
667
+ });
668
+ `);
669
+
670
+ expect(result.errors[0]?.methodName).toBe("nav");
671
+ });
672
+
673
+ test("emits ParseError when the definition is not a plain object", () => {
674
+ const result = parseInline(`
675
+ defineFeature("f", (r) => {
676
+ r.nav(() => {});
677
+ });
678
+ `);
679
+
680
+ expect(result.errors[0]?.methodName).toBe("nav");
681
+ });
367
682
  });
368
683
 
369
684
  describe("extractWorkspace", () => {
@@ -379,6 +694,16 @@ defineFeature("f", (r) => {
379
694
  definition: { id: "admin", label: "Admin" },
380
695
  });
381
696
  });
697
+
698
+ test("emits ParseError when called with no arguments", () => {
699
+ const result = parseInline(`
700
+ defineFeature("f", (r) => {
701
+ r.workspace();
702
+ });
703
+ `);
704
+
705
+ expect(result.errors[0]?.methodName).toBe("workspace");
706
+ });
382
707
  });
383
708
 
384
709
  // =============================================================================
@@ -412,6 +737,26 @@ defineFeature("f", (r) => {
412
737
  defineFeature("f", (r) => {
413
738
  r.config({});
414
739
  });
740
+ `);
741
+
742
+ expect(result.errors[0]?.methodName).toBe("config");
743
+ });
744
+
745
+ test("emits ParseError when called with no arguments", () => {
746
+ const result = parseInline(`
747
+ defineFeature("f", (r) => {
748
+ r.config();
749
+ });
750
+ `);
751
+
752
+ expect(result.errors[0]?.methodName).toBe("config");
753
+ });
754
+
755
+ test("emits ParseError when the argument is not a plain object", () => {
756
+ const result = parseInline(`
757
+ defineFeature("f", (r) => {
758
+ r.config(() => {});
759
+ });
415
760
  `);
416
761
 
417
762
  expect(result.errors[0]?.methodName).toBe("config");
@@ -439,6 +784,26 @@ defineFeature("f", (r) => {
439
784
  },
440
785
  });
441
786
  });
787
+
788
+ test("emits ParseError when called with no arguments", () => {
789
+ const result = parseInline(`
790
+ defineFeature("f", (r) => {
791
+ r.translations();
792
+ });
793
+ `);
794
+
795
+ expect(result.errors[0]?.methodName).toBe("translations");
796
+ });
797
+
798
+ test("emits ParseError when keys property is missing", () => {
799
+ const result = parseInline(`
800
+ defineFeature("f", (r) => {
801
+ r.translations({});
802
+ });
803
+ `);
804
+
805
+ expect(result.errors[0]?.methodName).toBe("translations");
806
+ });
442
807
  });
443
808
 
444
809
  describe("extractMetric", () => {
@@ -455,336 +820,1372 @@ defineFeature("f", (r) => {
455
820
  options: { type: "counter", description: "API requests" },
456
821
  });
457
822
  });
458
- });
823
+
824
+ test("object form captures name + options", () => {
825
+ const result = parseInline(`
826
+ defineFeature("f", (r) => {
827
+ r.metric({ name: "requests", type: "counter", description: "API requests" });
828
+ });
829
+ `);
830
+
831
+ expect(result.patterns[0]).toMatchObject({
832
+ kind: "metric",
833
+ shortName: "requests",
834
+ options: { type: "counter", description: "API requests" },
835
+ });
836
+ });
837
+
838
+ test("emits ParseError when called with no arguments", () => {
839
+ const result = parseInline(`
840
+ defineFeature("f", (r) => {
841
+ r.metric();
842
+ });
843
+ `);
844
+
845
+ expect(result.errors[0]?.methodName).toBe("metric");
846
+ });
847
+
848
+ test("emits ParseError when object form is missing the name property", () => {
849
+ const result = parseInline(`
850
+ defineFeature("f", (r) => {
851
+ r.metric({ type: "counter" });
852
+ });
853
+ `);
854
+
855
+ expect(result.errors[0]?.methodName).toBe("metric");
856
+ });
857
+
858
+ test("emits ParseError when the name is not a string literal", () => {
859
+ const result = parseInline(`
860
+ defineFeature("f", (r) => {
861
+ r.metric(NAME, { type: "counter" });
862
+ });
863
+ `);
864
+
865
+ expect(result.errors[0]?.methodName).toBe("metric");
866
+ });
867
+
868
+ test("emits ParseError when the options argument is missing", () => {
869
+ const result = parseInline(`
870
+ defineFeature("f", (r) => {
871
+ r.metric("requests");
872
+ });
873
+ `);
874
+
875
+ expect(result.errors[0]?.methodName).toBe("metric");
876
+ });
877
+
878
+ test("emits ParseError when options is not a plain object", () => {
879
+ const result = parseInline(`
880
+ defineFeature("f", (r) => {
881
+ r.metric("requests", () => {});
882
+ });
883
+ `);
884
+
885
+ expect(result.errors[0]?.methodName).toBe("metric");
886
+ });
887
+ });
459
888
 
460
889
  describe("extractSecret", () => {
461
890
  test("captures shortName + options", () => {
462
891
  const result = parseInline(`
463
892
  defineFeature("f", (r) => {
464
- r.secret("apiKey", { description: "Stripe API key" });
893
+ r.secret("apiKey", { description: "Stripe API key" });
894
+ });
895
+ `);
896
+
897
+ expect(result.patterns[0]).toMatchObject({
898
+ kind: "secret",
899
+ shortName: "apiKey",
900
+ options: { description: "Stripe API key" },
901
+ });
902
+ });
903
+
904
+ test("object form captures name + options", () => {
905
+ const result = parseInline(`
906
+ defineFeature("f", (r) => {
907
+ r.secret({ name: "apiKey", description: "Stripe API key" });
908
+ });
909
+ `);
910
+
911
+ expect(result.patterns[0]).toMatchObject({
912
+ kind: "secret",
913
+ shortName: "apiKey",
914
+ options: { description: "Stripe API key" },
915
+ });
916
+ });
917
+
918
+ test("emits ParseError when called with no arguments", () => {
919
+ const result = parseInline(`
920
+ defineFeature("f", (r) => {
921
+ r.secret();
922
+ });
923
+ `);
924
+
925
+ expect(result.errors[0]?.methodName).toBe("secret");
926
+ });
927
+ });
928
+
929
+ describe("extractClaimKey", () => {
930
+ test("captures shortName + claim type", () => {
931
+ const result = parseInline(`
932
+ defineFeature("f", (r) => {
933
+ r.claimKey("teamId", { type: "string" });
934
+ });
935
+ `);
936
+
937
+ expect(result.patterns[0]).toMatchObject({
938
+ kind: "claimKey",
939
+ shortName: "teamId",
940
+ claimType: "string",
941
+ });
942
+ });
943
+
944
+ test("emits ParseError on invalid claim type", () => {
945
+ const result = parseInline(`
946
+ defineFeature("f", (r) => {
947
+ r.claimKey("teamId", { type: "bigint" });
948
+ });
949
+ `);
950
+
951
+ expect(result.errors[0]?.methodName).toBe("claimKey");
952
+ });
953
+
954
+ test("object form captures name + type", () => {
955
+ const result = parseInline(`
956
+ defineFeature("f", (r) => {
957
+ r.claimKey({ name: "teamId", type: "string" });
958
+ });
959
+ `);
960
+
961
+ expect(result.patterns[0]).toMatchObject({
962
+ kind: "claimKey",
963
+ shortName: "teamId",
964
+ claimType: "string",
965
+ });
966
+ });
967
+
968
+ test("emits ParseError when called with no arguments", () => {
969
+ const result = parseInline(`
970
+ defineFeature("f", (r) => {
971
+ r.claimKey();
972
+ });
973
+ `);
974
+
975
+ expect(result.errors[0]?.methodName).toBe("claimKey");
976
+ });
977
+ });
978
+
979
+ describe("extractReferenceData", () => {
980
+ test("captures entity name + data array", () => {
981
+ const result = parseInline(`
982
+ defineFeature("f", (r) => {
983
+ r.referenceData("status", [
984
+ { id: "open", label: "Open" },
985
+ { id: "closed", label: "Closed" },
986
+ ]);
987
+ });
988
+ `);
989
+
990
+ expect(result.patterns[0]).toMatchObject({
991
+ kind: "referenceData",
992
+ entityName: "status",
993
+ data: [
994
+ { id: "open", label: "Open" },
995
+ { id: "closed", label: "Closed" },
996
+ ],
997
+ });
998
+ });
999
+
1000
+ test("captures the optional upsertKey", () => {
1001
+ const result = parseInline(`
1002
+ defineFeature("f", (r) => {
1003
+ r.referenceData("status", [{ id: "open" }], { upsertKey: "id" });
1004
+ });
1005
+ `);
1006
+
1007
+ expect(result.patterns[0]).toMatchObject({
1008
+ kind: "referenceData",
1009
+ entityName: "status",
1010
+ upsertKey: "id",
1011
+ });
1012
+ });
1013
+
1014
+ test("object form captures entity, data and upsertKey", () => {
1015
+ const result = parseInline(`
1016
+ defineFeature("f", (r) => {
1017
+ r.referenceData({
1018
+ entity: "status",
1019
+ data: [{ id: "open" }, { id: "closed" }],
1020
+ upsertKey: "id",
1021
+ });
1022
+ });
1023
+ `);
1024
+
1025
+ expect(result.errors).toEqual([]);
1026
+ expect(result.patterns[0]).toMatchObject({
1027
+ kind: "referenceData",
1028
+ entityName: "status",
1029
+ data: [{ id: "open" }, { id: "closed" }],
1030
+ upsertKey: "id",
1031
+ });
1032
+ });
1033
+
1034
+ test("accepts an inline { name: '...' } entity ref, both forms", () => {
1035
+ const positional = parseInline(`
1036
+ defineFeature("f", (r) => {
1037
+ r.referenceData({ name: "status" }, [{ id: "open" }]);
1038
+ });
1039
+ `);
1040
+ expect(positional.patterns[0]).toMatchObject({ kind: "referenceData", entityName: "status" });
1041
+
1042
+ const objectForm = parseInline(`
1043
+ defineFeature("f", (r) => {
1044
+ r.referenceData({ entity: { name: "status" }, data: [{ id: "open" }] });
1045
+ });
1046
+ `);
1047
+ expect(objectForm.patterns[0]).toMatchObject({ kind: "referenceData", entityName: "status" });
1048
+ });
1049
+
1050
+ test("emits ParseError when called with no arguments", () => {
1051
+ const result = parseInline(`
1052
+ defineFeature("f", (r) => {
1053
+ r.referenceData();
1054
+ });
1055
+ `);
1056
+
1057
+ expect(result.patterns).toEqual([]);
1058
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1059
+ });
1060
+
1061
+ test("emits ParseError when object form is missing the data property", () => {
1062
+ const result = parseInline(`
1063
+ defineFeature("f", (r) => {
1064
+ r.referenceData({ entity: "status" });
1065
+ });
1066
+ `);
1067
+
1068
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1069
+ });
1070
+
1071
+ test("emits ParseError when object form is missing the entity property", () => {
1072
+ const result = parseInline(`
1073
+ defineFeature("f", (r) => {
1074
+ r.referenceData({ data: [{ id: "open" }] });
1075
+ });
1076
+ `);
1077
+
1078
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1079
+ });
1080
+
1081
+ test("emits ParseError when object form's data is not an array of plain objects", () => {
1082
+ const result = parseInline(`
1083
+ defineFeature("f", (r) => {
1084
+ r.referenceData({ entity: "status", data: "not-an-array" });
1085
+ });
1086
+ `);
1087
+
1088
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1089
+ });
1090
+
1091
+ test("emits ParseError when the positional data argument is missing", () => {
1092
+ const result = parseInline(`
1093
+ defineFeature("f", (r) => {
1094
+ r.referenceData("status");
1095
+ });
1096
+ `);
1097
+
1098
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1099
+ });
1100
+
1101
+ test("emits ParseError when positional upsertKey option is not a string", () => {
1102
+ const result = parseInline(`
1103
+ defineFeature("f", (r) => {
1104
+ r.referenceData("status", [{ id: "open" }], { upsertKey: 123 });
1105
+ });
1106
+ `);
1107
+
1108
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1109
+ });
1110
+
1111
+ test("emits ParseError when object form entity ref is unresolvable", () => {
1112
+ const result = parseInline(`
1113
+ const ent = { name: "status" };
1114
+ defineFeature("f", (r) => {
1115
+ r.referenceData({ entity: ent, data: [{ id: "open" }] });
1116
+ });
1117
+ `);
1118
+
1119
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1120
+ });
1121
+
1122
+ test("emits ParseError when positional data is not an array of plain objects", () => {
1123
+ const result = parseInline(`
1124
+ defineFeature("f", (r) => {
1125
+ r.referenceData("status", "not-an-array");
1126
+ });
1127
+ `);
1128
+
1129
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1130
+ });
1131
+
1132
+ test("emits ParseError when positional options is not a plain object", () => {
1133
+ const result = parseInline(`
1134
+ defineFeature("f", (r) => {
1135
+ r.referenceData("status", [{ id: "open" }], () => {});
1136
+ });
1137
+ `);
1138
+
1139
+ expect(result.errors[0]?.methodName).toBe("referenceData");
1140
+ });
1141
+ });
1142
+
1143
+ describe("extractUseExtension", () => {
1144
+ test("captures extension name + entity ref", () => {
1145
+ const result = parseInline(`
1146
+ defineFeature("f", (r) => {
1147
+ r.useExtension("audit", "task");
1148
+ });
1149
+ `);
1150
+
1151
+ expect(result.patterns[0]).toMatchObject({
1152
+ kind: "useExtension",
1153
+ extensionName: "audit",
1154
+ entityName: "task",
1155
+ });
1156
+ });
1157
+
1158
+ test("captures optional options", () => {
1159
+ const result = parseInline(`
1160
+ defineFeature("f", (r) => {
1161
+ r.useExtension("audit", "task", { mode: "verbose" });
1162
+ });
1163
+ `);
1164
+
1165
+ expect(result.patterns[0]).toMatchObject({
1166
+ kind: "useExtension",
1167
+ extensionName: "audit",
1168
+ entityName: "task",
1169
+ options: { mode: "verbose" },
1170
+ });
1171
+ });
1172
+
1173
+ test("object form captures name, entity ref and options", () => {
1174
+ const result = parseInline(`
1175
+ defineFeature("f", (r) => {
1176
+ r.useExtension({ name: "audit", entity: "task", options: { mode: "verbose" } });
1177
+ });
1178
+ `);
1179
+
1180
+ expect(result.errors).toEqual([]);
1181
+ expect(result.patterns[0]).toMatchObject({
1182
+ kind: "useExtension",
1183
+ extensionName: "audit",
1184
+ entityName: "task",
1185
+ options: { mode: "verbose" },
1186
+ });
1187
+ });
1188
+
1189
+ test("accepts an inline { name: '...' } entity ref, both forms", () => {
1190
+ const positional = parseInline(`
1191
+ defineFeature("f", (r) => {
1192
+ r.useExtension("audit", { name: "task" });
1193
+ });
1194
+ `);
1195
+ expect(positional.patterns[0]).toMatchObject({ kind: "useExtension", entityName: "task" });
1196
+
1197
+ const objectForm = parseInline(`
1198
+ defineFeature("f", (r) => {
1199
+ r.useExtension({ name: "audit", entity: { name: "task" } });
1200
+ });
1201
+ `);
1202
+ expect(objectForm.patterns[0]).toMatchObject({ kind: "useExtension", entityName: "task" });
1203
+ });
1204
+
1205
+ test("emits ParseError when called with no arguments", () => {
1206
+ const result = parseInline(`
1207
+ defineFeature("f", (r) => {
1208
+ r.useExtension();
1209
+ });
1210
+ `);
1211
+
1212
+ expect(result.patterns).toEqual([]);
1213
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1214
+ });
1215
+
1216
+ test("emits ParseError when object form is missing the entity property", () => {
1217
+ const result = parseInline(`
1218
+ defineFeature("f", (r) => {
1219
+ r.useExtension({ name: "audit" });
1220
+ });
1221
+ `);
1222
+
1223
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1224
+ });
1225
+
1226
+ test("emits ParseError when object form's options is not a plain object", () => {
1227
+ const result = parseInline(`
1228
+ defineFeature("f", (r) => {
1229
+ r.useExtension({ name: "audit", entity: "task", options: "not-an-object" });
1230
+ });
1231
+ `);
1232
+
1233
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1234
+ });
1235
+
1236
+ test("emits ParseError when the positional entity argument is missing", () => {
1237
+ const result = parseInline(`
1238
+ defineFeature("f", (r) => {
1239
+ r.useExtension("audit");
1240
+ });
1241
+ `);
1242
+
1243
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1244
+ });
1245
+
1246
+ test("emits ParseError when positional options argument is not a plain object", () => {
1247
+ const result = parseInline(`
1248
+ defineFeature("f", (r) => {
1249
+ r.useExtension("audit", "task", "not-an-object");
1250
+ });
1251
+ `);
1252
+
1253
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1254
+ });
1255
+
1256
+ test("emits ParseError when the extension name is not a string literal", () => {
1257
+ const result = parseInline(`
1258
+ defineFeature("f", (r) => {
1259
+ r.useExtension(EXT, "task");
1260
+ });
1261
+ `);
1262
+
1263
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1264
+ });
1265
+
1266
+ test("emits ParseError when the positional entity ref is unresolvable", () => {
1267
+ const result = parseInline(`
1268
+ const ent = { name: "task" };
1269
+ defineFeature("f", (r) => {
1270
+ r.useExtension("audit", ent);
1271
+ });
1272
+ `);
1273
+
1274
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1275
+ });
1276
+
1277
+ test("emits ParseError when object form is missing the name property", () => {
1278
+ const result = parseInline(`
1279
+ defineFeature("f", (r) => {
1280
+ r.useExtension({ entity: "task" });
1281
+ });
1282
+ `);
1283
+
1284
+ expect(result.errors[0]?.methodName).toBe("useExtension");
1285
+ });
1286
+ });
1287
+
1288
+ // =============================================================================
1289
+ // Round 4 extractors — mixed patterns (header data + opaque body source)
1290
+ // =============================================================================
1291
+
1292
+ describe("extractHook", () => {
1293
+ test("captures hookType, target and the function body", () => {
1294
+ const result = parseInline(`
1295
+ defineFeature("f", (r) => {
1296
+ r.hook("postSave", "task", (event, ctx) => { console.log(event); });
1297
+ });
1298
+ `);
1299
+
1300
+ expect(result.patterns[0]).toMatchObject({
1301
+ kind: "hook",
1302
+ hookType: "postSave",
1303
+ target: "task",
1304
+ });
1305
+ const fnBody = (result.patterns[0] as { fnBody?: { raw: string } } | undefined)?.fnBody;
1306
+ expect(fnBody?.raw).toContain("(event, ctx)");
1307
+ });
1308
+
1309
+ test("captures the optional phase from the options object", () => {
1310
+ const result = parseInline(`
1311
+ defineFeature("f", (r) => {
1312
+ r.hook("postSave", "task", (event, ctx) => {}, { phase: "afterCommit" });
1313
+ });
1314
+ `);
1315
+
1316
+ expect(result.patterns[0]).toMatchObject({ kind: "hook", phase: "afterCommit" });
1317
+ });
1318
+
1319
+ test("rejects an unknown hook type", () => {
1320
+ const result = parseInline(`
1321
+ defineFeature("f", (r) => {
1322
+ r.hook("postCommit", "task", () => {});
1323
+ });
1324
+ `);
1325
+
1326
+ expect(result.errors[0]?.methodName).toBe("hook");
1327
+ });
1328
+
1329
+ test("object form captures type, target and handler", () => {
1330
+ const result = parseInline(`
1331
+ defineFeature("f", (r) => {
1332
+ r.hook({ type: "preSave", target: "task", handler: (event, ctx) => {} });
1333
+ });
1334
+ `);
1335
+
1336
+ expect(result.patterns[0]).toMatchObject({
1337
+ kind: "hook",
1338
+ hookType: "preSave",
1339
+ target: "task",
1340
+ });
1341
+ });
1342
+
1343
+ test("emits ParseError when object form has an unknown hook type", () => {
1344
+ const result = parseInline(`
1345
+ defineFeature("f", (r) => {
1346
+ r.hook({ type: "postCommit", target: "task", handler: () => {} });
1347
+ });
1348
+ `);
1349
+
1350
+ expect(result.errors[0]?.methodName).toBe("hook");
1351
+ });
1352
+
1353
+ test("emits ParseError when object form is missing the handler property", () => {
1354
+ const result = parseInline(`
1355
+ defineFeature("f", (r) => {
1356
+ r.hook({ type: "postSave", target: "task" });
1357
+ });
1358
+ `);
1359
+
1360
+ expect(result.errors[0]?.methodName).toBe("hook");
1361
+ });
1362
+
1363
+ test("emits ParseError when object form handler is not an inline function", () => {
1364
+ const result = parseInline(`
1365
+ defineFeature("f", (r) => {
1366
+ r.hook({ type: "postSave", target: "task", handler: handlerRef });
1367
+ });
1368
+ `);
1369
+
1370
+ expect(result.errors[0]?.methodName).toBe("hook");
1371
+ });
1372
+
1373
+ test("captures a multi-entity target array", () => {
1374
+ const result = parseInline(`
1375
+ defineFeature("f", (r) => {
1376
+ r.hook("postSave", ["task", "note"], () => {});
1377
+ });
1378
+ `);
1379
+
1380
+ expect(result.patterns[0]).toMatchObject({
1381
+ kind: "hook",
1382
+ target: ["task", "note"],
1383
+ });
1384
+ });
1385
+
1386
+ test("emits ParseError when called with no arguments", () => {
1387
+ const result = parseInline(`
1388
+ defineFeature("f", (r) => {
1389
+ r.hook();
1390
+ });
1391
+ `);
1392
+
1393
+ expect(result.errors[0]?.methodName).toBe("hook");
1394
+ });
1395
+
1396
+ test("emits ParseError when object form is missing the type property", () => {
1397
+ const result = parseInline(`
1398
+ defineFeature("f", (r) => {
1399
+ r.hook({ target: "task", handler: () => {} });
1400
+ });
1401
+ `);
1402
+
1403
+ expect(result.errors[0]?.methodName).toBe("hook");
1404
+ });
1405
+
1406
+ test("emits ParseError when object form is missing the target property", () => {
1407
+ const result = parseInline(`
1408
+ defineFeature("f", (r) => {
1409
+ r.hook({ type: "postSave", handler: () => {} });
1410
+ });
1411
+ `);
1412
+
1413
+ expect(result.errors[0]?.methodName).toBe("hook");
1414
+ });
1415
+
1416
+ test("emits ParseError when the handler is not an inline function", () => {
1417
+ const result = parseInline(`
1418
+ defineFeature("f", (r) => {
1419
+ r.hook("postSave", "task", handlerRef);
1420
+ });
1421
+ `);
1422
+
1423
+ expect(result.errors[0]?.methodName).toBe("hook");
1424
+ });
1425
+
1426
+ test("emits ParseError when the hook type is not a string literal", () => {
1427
+ const result = parseInline(`
1428
+ defineFeature("f", (r) => {
1429
+ r.hook(TYPE, "task", () => {});
1430
+ });
1431
+ `);
1432
+
1433
+ expect(result.errors[0]?.methodName).toBe("hook");
1434
+ });
1435
+
1436
+ test("emits ParseError when the target argument is missing", () => {
1437
+ const result = parseInline(`
1438
+ defineFeature("f", (r) => {
1439
+ r.hook("postSave");
1440
+ });
1441
+ `);
1442
+
1443
+ expect(result.errors[0]?.methodName).toBe("hook");
1444
+ });
1445
+
1446
+ test("emits ParseError when object form target is not a valid ref", () => {
1447
+ const result = parseInline(`
1448
+ defineFeature("f", (r) => {
1449
+ r.hook({ type: "postSave", target: 123, handler: () => {} });
1450
+ });
1451
+ `);
1452
+
1453
+ expect(result.errors[0]?.methodName).toBe("hook");
1454
+ });
1455
+
1456
+ test("emits ParseError when the hook function argument is missing", () => {
1457
+ const result = parseInline(`
1458
+ defineFeature("f", (r) => {
1459
+ r.hook("postSave", "task");
1460
+ });
1461
+ `);
1462
+
1463
+ expect(result.errors[0]?.methodName).toBe("hook");
1464
+ });
1465
+ });
1466
+
1467
+ describe("extractHook — entity-wide { allOf } target", () => {
1468
+ test("captures hookType and the allOf entity from positional form", () => {
1469
+ const result = parseInline(`
1470
+ defineFeature("f", (r) => {
1471
+ r.hook("postSave", { allOf: "task" }, (event, ctx) => {});
1472
+ });
1473
+ `);
1474
+
1475
+ expect(result.patterns[0]).toMatchObject({
1476
+ kind: "hook",
1477
+ hookType: "postSave",
1478
+ target: { allOf: "task" },
1479
+ });
1480
+ });
1481
+
1482
+ test("captures hookType and the allOf entity from object form", () => {
1483
+ const result = parseInline(`
1484
+ defineFeature("f", (r) => {
1485
+ r.hook({ type: "postDelete", target: { allOf: "task" }, handler: () => {} });
1486
+ });
1487
+ `);
1488
+
1489
+ expect(result.patterns[0]).toMatchObject({
1490
+ kind: "hook",
1491
+ hookType: "postDelete",
1492
+ target: { allOf: "task" },
1493
+ });
1494
+ });
1495
+
1496
+ test("rejects a malformed allOf value (not a string/ref)", () => {
1497
+ const result = parseInline(`
1498
+ defineFeature("f", (r) => {
1499
+ r.hook("postSave", { allOf: 123 }, () => {});
1500
+ });
1501
+ `);
1502
+
1503
+ expect(result.errors[0]?.methodName).toBe("hook");
1504
+ });
1505
+ });
1506
+
1507
+ describe("extractAuthClaims", () => {
1508
+ test("captures the function body as a SourceLocation", () => {
1509
+ const result = parseInline(`
1510
+ defineFeature("f", (r) => {
1511
+ r.authClaims(async (user, ctx) => ({ teamId: "t1" }));
1512
+ });
1513
+ `);
1514
+
1515
+ expect(result.patterns[0]).toMatchObject({ kind: "authClaims" });
1516
+ });
1517
+
1518
+ test("emits ParseError when called with no arguments", () => {
1519
+ const result = parseInline(`
1520
+ defineFeature("f", (r) => {
1521
+ r.authClaims();
1522
+ });
1523
+ `);
1524
+
1525
+ expect(result.errors[0]?.methodName).toBe("authClaims");
1526
+ });
1527
+
1528
+ test("emits ParseError when the argument is not an inline function", () => {
1529
+ const result = parseInline(`
1530
+ defineFeature("f", (r) => {
1531
+ r.authClaims(handlerRef);
1532
+ });
1533
+ `);
1534
+
1535
+ expect(result.errors[0]?.methodName).toBe("authClaims");
1536
+ });
1537
+ });
1538
+
1539
+ describe("extractWriteHandler", () => {
1540
+ test("inline form: name, schema, handler, options", () => {
1541
+ const result = parseInline(`
1542
+ defineFeature("f", (r) => {
1543
+ r.writeHandler("task:create", z.object({ title: z.string() }), async (event, ctx) => {
1544
+ return { isSuccess: true, data: {} };
1545
+ }, { access: { roles: ["admin"] } });
1546
+ });
1547
+ `);
1548
+
1549
+ expect(result.patterns[0]).toMatchObject({
1550
+ kind: "writeHandler",
1551
+ handlerName: "task:create",
1552
+ access: { roles: ["admin"] },
1553
+ });
1554
+ });
1555
+
1556
+ test("object form: defineWriteHandler shape", () => {
1557
+ const result = parseInline(`
1558
+ defineFeature("f", (r) => {
1559
+ r.writeHandler({
1560
+ name: "task:approve",
1561
+ schema: z.object({ id: z.string() }),
1562
+ handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1563
+ access: { openToAll: true },
1564
+ });
1565
+ });
1566
+ `);
1567
+
1568
+ expect(result.patterns[0]).toMatchObject({
1569
+ kind: "writeHandler",
1570
+ handlerName: "task:approve",
1571
+ access: { openToAll: true },
1572
+ });
1573
+ });
1574
+
1575
+ test("resolves a same-file identifier arg to its object-literal form (#1007)", () => {
1576
+ const result = parseInline(`
1577
+ const h = {
1578
+ name: "task:archive",
1579
+ schema: z.object({ id: z.string() }),
1580
+ handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1581
+ };
1582
+ defineFeature("f", (r) => {
1583
+ r.writeHandler(h);
1584
+ });
1585
+ `);
1586
+
1587
+ expect(result.patterns[0]).toMatchObject({
1588
+ kind: "writeHandler",
1589
+ handlerName: "task:archive",
1590
+ });
1591
+ expect(result.errors).toEqual([]);
1592
+ });
1593
+
1594
+ test("keeps an unresolvable ref (import, factory call) as an opaque pattern instead of failing (#1007)", () => {
1595
+ const result = parseInline(`
1596
+ defineFeature("f", (r) => {
1597
+ r.writeHandler(eventCreateHandler);
1598
+ });
1599
+ `);
1600
+
1601
+ expect(result.patterns[0]).toMatchObject({
1602
+ kind: "writeHandler",
1603
+ handlerName: undefined,
1604
+ source: { raw: "r.writeHandler(eventCreateHandler)" },
1605
+ });
1606
+ expect(result.errors).toEqual([]);
1607
+ });
1608
+ });
1609
+
1610
+ describe("extractQueryHandler", () => {
1611
+ test("inline form returns kind=queryHandler", () => {
1612
+ const result = parseInline(`
1613
+ defineFeature("f", (r) => {
1614
+ r.queryHandler("task:list", z.object({}), async (q, ctx) => []);
1615
+ });
1616
+ `);
1617
+
1618
+ expect(result.patterns[0]).toMatchObject({
1619
+ kind: "queryHandler",
1620
+ handlerName: "task:list",
1621
+ });
1622
+ });
1623
+ });
1624
+
1625
+ describe("extractStreamHandler", () => {
1626
+ test("inline form: name, schema, handler, options", () => {
1627
+ const result = parseInline(`
1628
+ defineFeature("f", (r) => {
1629
+ r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* (input, ctx) {
1630
+ yield "token";
1631
+ }, { access: { roles: ["User"] } });
1632
+ });
1633
+ `);
1634
+
1635
+ expect(result.patterns[0]).toMatchObject({
1636
+ kind: "streamHandler",
1637
+ handlerName: "chat:complete",
1638
+ access: { roles: ["User"] },
1639
+ });
1640
+ });
1641
+
1642
+ test("object form: name, schema, handler", () => {
1643
+ const result = parseInline(`
1644
+ defineFeature("f", (r) => {
1645
+ r.streamHandler({
1646
+ name: "chat:complete",
1647
+ schema: z.object({ prompt: z.string() }),
1648
+ handler: async function* (input, ctx) { yield "token"; },
1649
+ access: { openToAll: true },
1650
+ });
1651
+ });
1652
+ `);
1653
+
1654
+ expect(result.patterns[0]).toMatchObject({
1655
+ kind: "streamHandler",
1656
+ handlerName: "chat:complete",
1657
+ access: { openToAll: true },
1658
+ });
1659
+ });
1660
+
1661
+ test("keeps an unresolvable ref as an opaque pattern instead of failing", () => {
1662
+ const result = parseInline(`
1663
+ defineFeature("f", (r) => {
1664
+ r.streamHandler(chatCompleteHandler);
1665
+ });
1666
+ `);
1667
+
1668
+ expect(result.patterns[0]).toMatchObject({
1669
+ kind: "streamHandler",
1670
+ handlerName: undefined,
1671
+ source: { raw: "r.streamHandler(chatCompleteHandler)" },
1672
+ });
1673
+ expect(result.errors).toEqual([]);
1674
+ });
1675
+ });
1676
+
1677
+ describe("extractJob", () => {
1678
+ test("captures jobName, options and handler body", () => {
1679
+ const result = parseInline(`
1680
+ defineFeature("f", (r) => {
1681
+ r.job("daily-cleanup", { trigger: { cron: "0 3 * * *" } }, async (ctx) => {});
1682
+ });
1683
+ `);
1684
+
1685
+ expect(result.patterns[0]).toMatchObject({
1686
+ kind: "job",
1687
+ jobName: "daily-cleanup",
1688
+ });
1689
+ });
1690
+
1691
+ test("object form captures name, handler and extra options", () => {
1692
+ const result = parseInline(`
1693
+ defineFeature("f", (r) => {
1694
+ r.job({
1695
+ name: "daily-cleanup",
1696
+ trigger: { cron: "0 3 * * *" },
1697
+ handler: async (ctx) => {},
1698
+ });
1699
+ });
1700
+ `);
1701
+
1702
+ expect(result.patterns[0]).toMatchObject({
1703
+ kind: "job",
1704
+ jobName: "daily-cleanup",
1705
+ options: { trigger: { cron: "0 3 * * *" } },
1706
+ });
1707
+ });
1708
+
1709
+ test("emits ParseError when called with no arguments", () => {
1710
+ const result = parseInline(`
1711
+ defineFeature("f", (r) => {
1712
+ r.job();
1713
+ });
1714
+ `);
1715
+
1716
+ expect(result.errors[0]?.methodName).toBe("job");
1717
+ });
1718
+
1719
+ test("emits ParseError when object form is missing the name property", () => {
1720
+ const result = parseInline(`
1721
+ defineFeature("f", (r) => {
1722
+ r.job({ handler: async () => {} });
1723
+ });
1724
+ `);
1725
+
1726
+ expect(result.errors[0]?.methodName).toBe("job");
1727
+ });
1728
+
1729
+ test("emits ParseError when object form is missing the handler property", () => {
1730
+ const result = parseInline(`
1731
+ defineFeature("f", (r) => {
1732
+ r.job({ name: "daily-cleanup", trigger: { cron: "0 3 * * *" } });
1733
+ });
1734
+ `);
1735
+
1736
+ expect(result.errors[0]?.methodName).toBe("job");
1737
+ });
1738
+
1739
+ test("emits ParseError when object form option value is not readable", () => {
1740
+ const result = parseInline(`
1741
+ defineFeature("f", (r) => {
1742
+ r.job({ name: "daily-cleanup", handler: async () => {}, onComplete: () => {} });
1743
+ });
1744
+ `);
1745
+
1746
+ expect(result.errors[0]?.methodName).toBe("job");
1747
+ });
1748
+
1749
+ test("emits ParseError when object form handler is not an inline function", () => {
1750
+ const result = parseInline(`
1751
+ defineFeature("f", (r) => {
1752
+ r.job({ name: "daily-cleanup", handler: handlerRef });
1753
+ });
1754
+ `);
1755
+
1756
+ expect(result.errors[0]?.methodName).toBe("job");
1757
+ });
1758
+
1759
+ test("emits ParseError when the job name is not a string literal", () => {
1760
+ const result = parseInline(`
1761
+ defineFeature("f", (r) => {
1762
+ r.job(NAME, {}, async () => {});
1763
+ });
1764
+ `);
1765
+
1766
+ expect(result.errors[0]?.methodName).toBe("job");
1767
+ });
1768
+
1769
+ test("emits ParseError when the options argument is missing", () => {
1770
+ const result = parseInline(`
1771
+ defineFeature("f", (r) => {
1772
+ r.job("daily-cleanup");
1773
+ });
1774
+ `);
1775
+
1776
+ expect(result.errors[0]?.methodName).toBe("job");
1777
+ });
1778
+
1779
+ test("emits ParseError when the handler argument is missing", () => {
1780
+ const result = parseInline(`
1781
+ defineFeature("f", (r) => {
1782
+ r.job("daily-cleanup", { trigger: { cron: "0 3 * * *" } });
1783
+ });
1784
+ `);
1785
+
1786
+ expect(result.errors[0]?.methodName).toBe("job");
1787
+ });
1788
+
1789
+ test("emits ParseError when options is not a plain object", () => {
1790
+ const result = parseInline(`
1791
+ defineFeature("f", (r) => {
1792
+ r.job("daily-cleanup", () => {}, async () => {});
1793
+ });
1794
+ `);
1795
+
1796
+ expect(result.errors[0]?.methodName).toBe("job");
1797
+ });
1798
+
1799
+ test("emits ParseError when the positional handler is not an inline function", () => {
1800
+ const result = parseInline(`
1801
+ defineFeature("f", (r) => {
1802
+ r.job("daily-cleanup", { trigger: { cron: "0 3 * * *" } }, handlerRef);
1803
+ });
1804
+ `);
1805
+
1806
+ expect(result.errors[0]?.methodName).toBe("job");
1807
+ });
1808
+ });
1809
+
1810
+ describe("extractHttpRoute", () => {
1811
+ test("captures method, path, anonymous, handler", () => {
1812
+ const result = parseInline(`
1813
+ defineFeature("f", (r) => {
1814
+ r.httpRoute({
1815
+ method: "GET",
1816
+ path: "/feed.xml",
1817
+ anonymous: true,
1818
+ handler: async (c) => new Response("ok"),
1819
+ });
1820
+ });
1821
+ `);
1822
+
1823
+ expect(result.patterns[0]).toMatchObject({
1824
+ kind: "httpRoute",
1825
+ method: "GET",
1826
+ path: "/feed.xml",
1827
+ anonymous: true,
1828
+ });
1829
+ });
1830
+
1831
+ test("emits ParseError when the argument is not an inline object", () => {
1832
+ const result = parseInline(`
1833
+ defineFeature("f", (r) => {
1834
+ r.httpRoute("GET", "/feed.xml");
1835
+ });
1836
+ `);
1837
+
1838
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
1839
+ });
1840
+
1841
+ test("emits ParseError when method is missing", () => {
1842
+ const result = parseInline(`
1843
+ defineFeature("f", (r) => {
1844
+ r.httpRoute({ path: "/feed.xml", handler: async () => {} });
465
1845
  });
466
1846
  `);
467
1847
 
468
- expect(result.patterns[0]).toMatchObject({
469
- kind: "secret",
470
- shortName: "apiKey",
471
- options: { description: "Stripe API key" },
472
- });
1848
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
473
1849
  });
474
- });
475
1850
 
476
- describe("extractClaimKey", () => {
477
- test("captures shortName + claim type", () => {
1851
+ test("emits ParseError when method is not a valid HTTP verb", () => {
478
1852
  const result = parseInline(`
479
1853
  defineFeature("f", (r) => {
480
- r.claimKey("teamId", { type: "string" });
1854
+ r.httpRoute({ method: "FETCH", path: "/feed.xml", handler: async () => {} });
481
1855
  });
482
1856
  `);
483
1857
 
484
- expect(result.patterns[0]).toMatchObject({
485
- kind: "claimKey",
486
- shortName: "teamId",
487
- claimType: "string",
488
- });
1858
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
489
1859
  });
490
1860
 
491
- test("emits ParseError on invalid claim type", () => {
1861
+ test("emits ParseError when path is missing", () => {
492
1862
  const result = parseInline(`
493
1863
  defineFeature("f", (r) => {
494
- r.claimKey("teamId", { type: "bigint" });
1864
+ r.httpRoute({ method: "GET", handler: async () => {} });
495
1865
  });
496
1866
  `);
497
1867
 
498
- expect(result.errors[0]?.methodName).toBe("claimKey");
1868
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
499
1869
  });
500
- });
501
1870
 
502
- describe("extractReferenceData", () => {
503
- test("captures entity name + data array", () => {
1871
+ test("emits ParseError when handler is missing", () => {
504
1872
  const result = parseInline(`
505
1873
  defineFeature("f", (r) => {
506
- r.referenceData("status", [
507
- { id: "open", label: "Open" },
508
- { id: "closed", label: "Closed" },
509
- ]);
1874
+ r.httpRoute({ method: "GET", path: "/feed.xml" });
510
1875
  });
511
1876
  `);
512
1877
 
513
- expect(result.patterns[0]).toMatchObject({
514
- kind: "referenceData",
515
- entityName: "status",
516
- data: [
517
- { id: "open", label: "Open" },
518
- { id: "closed", label: "Closed" },
519
- ],
520
- });
1878
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
521
1879
  });
522
1880
 
523
- test("captures the optional upsertKey", () => {
1881
+ test("emits ParseError when handler is not an inline function", () => {
524
1882
  const result = parseInline(`
525
1883
  defineFeature("f", (r) => {
526
- r.referenceData("status", [{ id: "open" }], { upsertKey: "id" });
1884
+ r.httpRoute({ method: "GET", path: "/feed.xml", handler: handlerRef });
527
1885
  });
528
1886
  `);
529
1887
 
530
- expect(result.patterns[0]).toMatchObject({
531
- kind: "referenceData",
532
- entityName: "status",
533
- upsertKey: "id",
534
- });
1888
+ expect(result.errors[0]?.methodName).toBe("httpRoute");
535
1889
  });
536
1890
  });
537
1891
 
538
- describe("extractUseExtension", () => {
539
- test("captures extension name + entity ref", () => {
1892
+ describe("extractDefineEvent", () => {
1893
+ test("captures eventName + version", () => {
540
1894
  const result = parseInline(`
541
1895
  defineFeature("f", (r) => {
542
- r.useExtension("audit", "task");
1896
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
543
1897
  });
544
1898
  `);
545
1899
 
546
1900
  expect(result.patterns[0]).toMatchObject({
547
- kind: "useExtension",
548
- extensionName: "audit",
549
- entityName: "task",
1901
+ kind: "defineEvent",
1902
+ eventName: "incidentOpened",
1903
+ version: 2,
550
1904
  });
551
1905
  });
552
1906
 
553
- test("captures optional options", () => {
1907
+ test("object form captures name, schema, version and migrations", () => {
554
1908
  const result = parseInline(`
555
1909
  defineFeature("f", (r) => {
556
- r.useExtension("audit", "task", { mode: "verbose" });
1910
+ r.defineEvent({
1911
+ name: "incidentOpened",
1912
+ schema: z.object({ id: z.string() }),
1913
+ version: 2,
1914
+ migrations: { "1": (payload) => payload },
1915
+ });
557
1916
  });
558
1917
  `);
559
1918
 
560
1919
  expect(result.patterns[0]).toMatchObject({
561
- kind: "useExtension",
562
- extensionName: "audit",
563
- entityName: "task",
564
- options: { mode: "verbose" },
1920
+ kind: "defineEvent",
1921
+ eventName: "incidentOpened",
1922
+ version: 2,
1923
+ migrations: { "1": expect.anything() },
565
1924
  });
566
1925
  });
567
- });
568
1926
 
569
- // =============================================================================
570
- // Round 4 extractors — mixed patterns (header data + opaque body source)
571
- // =============================================================================
572
-
573
- describe("extractHook", () => {
574
- test("captures hookType, target and the function body", () => {
1927
+ test("emits ParseError when called with no arguments", () => {
575
1928
  const result = parseInline(`
576
1929
  defineFeature("f", (r) => {
577
- r.hook("postSave", "task", (event, ctx) => { console.log(event); });
1930
+ r.defineEvent();
578
1931
  });
579
1932
  `);
580
1933
 
581
- expect(result.patterns[0]).toMatchObject({
582
- kind: "hook",
583
- hookType: "postSave",
584
- target: "task",
585
- });
586
- const fnBody = (result.patterns[0] as { fnBody?: { raw: string } } | undefined)?.fnBody;
587
- expect(fnBody?.raw).toContain("(event, ctx)");
1934
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
588
1935
  });
589
1936
 
590
- test("captures the optional phase from the options object", () => {
1937
+ test("emits ParseError when object form is missing the name property", () => {
591
1938
  const result = parseInline(`
592
1939
  defineFeature("f", (r) => {
593
- r.hook("postSave", "task", (event, ctx) => {}, { phase: "afterCommit" });
1940
+ r.defineEvent({ schema: z.object({ id: z.string() }) });
594
1941
  });
595
1942
  `);
596
1943
 
597
- expect(result.patterns[0]).toMatchObject({ kind: "hook", phase: "afterCommit" });
1944
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
598
1945
  });
599
1946
 
600
- test("rejects an unknown hook type", () => {
1947
+ test("emits ParseError when object form is missing the schema property", () => {
601
1948
  const result = parseInline(`
602
1949
  defineFeature("f", (r) => {
603
- r.hook("postCommit", "task", () => {});
1950
+ r.defineEvent({ name: "incidentOpened" });
604
1951
  });
605
1952
  `);
606
1953
 
607
- expect(result.errors[0]?.methodName).toBe("hook");
1954
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
608
1955
  });
609
- });
610
1956
 
611
- describe("extractEntityHook", () => {
612
- test("captures hookType, entity and the function body", () => {
1957
+ test("emits ParseError when the event name is not a string literal", () => {
613
1958
  const result = parseInline(`
614
1959
  defineFeature("f", (r) => {
615
- r.entityHook("postSave", "task", (event, ctx) => {});
1960
+ r.defineEvent(EVENT, z.object({ id: z.string() }));
616
1961
  });
617
1962
  `);
618
1963
 
619
- expect(result.patterns[0]).toMatchObject({
620
- kind: "entityHook",
621
- hookType: "postSave",
622
- entityName: "task",
623
- });
1964
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
624
1965
  });
625
1966
 
626
- test("rejects validation as entity-hook type (only postSave/preDelete/postDelete allowed)", () => {
1967
+ test("emits ParseError when the schema argument is missing", () => {
627
1968
  const result = parseInline(`
628
1969
  defineFeature("f", (r) => {
629
- r.entityHook("validation", "task", () => {});
1970
+ r.defineEvent("incidentOpened");
630
1971
  });
631
1972
  `);
632
1973
 
633
- expect(result.errors[0]?.methodName).toBe("entityHook");
1974
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
634
1975
  });
635
1976
  });
636
1977
 
637
- describe("extractAuthClaims", () => {
638
- test("captures the function body as a SourceLocation", () => {
1978
+ describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
1979
+ test("positional-form migrations array captures the fromVersion→transform step", () => {
639
1980
  const result = parseInline(`
640
1981
  defineFeature("f", (r) => {
641
- r.authClaims(async (user, ctx) => ({ teamId: "t1" }));
1982
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
1983
+ version: 2,
1984
+ migrations: [
1985
+ { fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
1986
+ ],
1987
+ });
642
1988
  });
643
1989
  `);
644
1990
 
645
- expect(result.patterns[0]).toMatchObject({ kind: "authClaims" });
1991
+ expect(result.patterns[0]).toMatchObject({
1992
+ kind: "defineEvent",
1993
+ eventName: "incidentOpened",
1994
+ version: 2,
1995
+ migrations: { "1": expect.anything() },
1996
+ });
646
1997
  });
647
- });
648
1998
 
649
- describe("extractWriteHandler", () => {
650
- test("inline form: name, schema, handler, options", () => {
1999
+ test("object-form migrations map captures the fromVersion→transform step", () => {
651
2000
  const result = parseInline(`
652
2001
  defineFeature("f", (r) => {
653
- r.writeHandler("task:create", z.object({ title: z.string() }), async (event, ctx) => {
654
- return { isSuccess: true, data: {} };
655
- }, { access: { roles: ["admin"] } });
2002
+ r.defineEvent({
2003
+ name: "incidentOpened",
2004
+ schema: z.object({ id: z.string() }),
2005
+ version: 2,
2006
+ migrations: {
2007
+ "1": (payload) => ({ ...payload, severity: "low" }),
2008
+ },
2009
+ });
656
2010
  });
657
2011
  `);
658
2012
 
659
2013
  expect(result.patterns[0]).toMatchObject({
660
- kind: "writeHandler",
661
- handlerName: "task:create",
662
- access: { roles: ["admin"] },
2014
+ kind: "defineEvent",
2015
+ eventName: "incidentOpened",
2016
+ version: 2,
2017
+ migrations: { "1": expect.anything() },
663
2018
  });
664
2019
  });
665
2020
 
666
- test("object form: defineWriteHandler shape", () => {
2021
+ test("skips malformed migration array entries instead of failing the whole extract", () => {
667
2022
  const result = parseInline(`
668
2023
  defineFeature("f", (r) => {
669
- r.writeHandler({
670
- name: "task:approve",
671
- schema: z.object({ id: z.string() }),
672
- handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
673
- access: { openToAll: true },
2024
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
2025
+ migrations: [
2026
+ "skip-me",
2027
+ { fromVersion: "bad", transform: (payload) => payload },
2028
+ { fromVersion: 1, transform: (payload) => ({ ...payload, ok: true }) },
2029
+ ],
674
2030
  });
675
2031
  });
676
2032
  `);
677
2033
 
2034
+ expect(result.errors).toEqual([]);
678
2035
  expect(result.patterns[0]).toMatchObject({
679
- kind: "writeHandler",
680
- handlerName: "task:approve",
681
- access: { openToAll: true },
2036
+ kind: "defineEvent",
2037
+ migrations: { "1": expect.anything() },
682
2038
  });
683
2039
  });
684
2040
  });
685
2041
 
686
- describe("extractQueryHandler", () => {
687
- test("inline form returns kind=queryHandler", () => {
2042
+ describe("extractNotification", () => {
2043
+ test("captures trigger + recipient + data bodies", () => {
688
2044
  const result = parseInline(`
689
2045
  defineFeature("f", (r) => {
690
- r.queryHandler("task:list", z.object({}), async (q, ctx) => []);
2046
+ r.notification("incidentOpened", {
2047
+ trigger: { on: "incident:create" },
2048
+ recipient: (event) => ({ tenant: event.tenantId }),
2049
+ data: (event) => ({ id: event.id }),
2050
+ });
691
2051
  });
692
2052
  `);
693
2053
 
694
2054
  expect(result.patterns[0]).toMatchObject({
695
- kind: "queryHandler",
696
- handlerName: "task:list",
2055
+ kind: "notification",
2056
+ notificationName: "incidentOpened",
2057
+ trigger: { on: "incident:create" },
697
2058
  });
698
2059
  });
699
- });
700
2060
 
701
- describe("extractJob", () => {
702
- test("captures jobName, options and handler body", () => {
2061
+ test("object form captures name + trigger/recipient/data", () => {
703
2062
  const result = parseInline(`
704
2063
  defineFeature("f", (r) => {
705
- r.job("daily-cleanup", { trigger: { cron: "0 3 * * *" } }, async (ctx) => {});
2064
+ r.notification({
2065
+ name: "incidentOpened",
2066
+ trigger: { on: "incident:create" },
2067
+ recipient: (event) => ({ tenant: event.tenantId }),
2068
+ data: (event) => ({ id: event.id }),
2069
+ });
706
2070
  });
707
2071
  `);
708
2072
 
709
2073
  expect(result.patterns[0]).toMatchObject({
710
- kind: "job",
711
- jobName: "daily-cleanup",
2074
+ kind: "notification",
2075
+ notificationName: "incidentOpened",
712
2076
  });
713
2077
  });
2078
+
2079
+ test("captures optional template bodies", () => {
2080
+ const result = parseInline(`
2081
+ defineFeature("f", (r) => {
2082
+ r.notification("incidentOpened", {
2083
+ trigger: { on: "incident:create" },
2084
+ recipient: (event) => ({}),
2085
+ data: (event) => ({}),
2086
+ templates: {
2087
+ email: (payload) => payload.subject,
2088
+ inApp: (payload) => payload.title,
2089
+ },
2090
+ });
714
2091
  });
2092
+ `);
715
2093
 
716
- describe("extractHttpRoute", () => {
717
- test("captures method, path, anonymous, handler", () => {
2094
+ const templates = (result.patterns[0] as { templates?: Record<string, unknown> } | undefined)
2095
+ ?.templates;
2096
+ expect(Object.keys(templates ?? {})).toEqual(["email", "inApp"]);
2097
+ });
2098
+
2099
+ test("emits ParseError when called with no arguments", () => {
718
2100
  const result = parseInline(`
719
2101
  defineFeature("f", (r) => {
720
- r.httpRoute({
721
- method: "GET",
722
- path: "/feed.xml",
723
- anonymous: true,
724
- handler: async (c) => new Response("ok"),
2102
+ r.notification();
2103
+ });
2104
+ `);
2105
+
2106
+ expect(result.errors[0]?.methodName).toBe("notification");
725
2107
  });
2108
+
2109
+ test("emits ParseError when object form is missing the name property", () => {
2110
+ const result = parseInline(`
2111
+ defineFeature("f", (r) => {
2112
+ r.notification({ trigger: { on: "x" }, recipient: () => ({}), data: () => ({}) });
726
2113
  });
727
2114
  `);
728
2115
 
729
- expect(result.patterns[0]).toMatchObject({
730
- kind: "httpRoute",
731
- method: "GET",
732
- path: "/feed.xml",
733
- anonymous: true,
734
- });
2116
+ expect(result.errors[0]?.methodName).toBe("notification");
735
2117
  });
2118
+
2119
+ test("emits ParseError when the notification name is not a string literal", () => {
2120
+ const result = parseInline(`
2121
+ defineFeature("f", (r) => {
2122
+ r.notification(NAME, { trigger: { on: "x" }, recipient: () => ({}), data: () => ({}) });
736
2123
  });
2124
+ `);
737
2125
 
738
- describe("extractDefineEvent", () => {
739
- test("captures eventName + version", () => {
2126
+ expect(result.errors[0]?.methodName).toBe("notification");
2127
+ });
2128
+
2129
+ test("emits ParseError when the definition object is missing", () => {
740
2130
  const result = parseInline(`
741
2131
  defineFeature("f", (r) => {
742
- r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
2132
+ r.notification("incidentOpened");
743
2133
  });
744
2134
  `);
745
2135
 
746
- expect(result.patterns[0]).toMatchObject({
747
- kind: "defineEvent",
748
- eventName: "incidentOpened",
749
- version: 2,
750
- });
2136
+ expect(result.errors[0]?.methodName).toBe("notification");
751
2137
  });
2138
+
2139
+ test("emits ParseError when trigger is missing", () => {
2140
+ const result = parseInline(`
2141
+ defineFeature("f", (r) => {
2142
+ r.notification("incidentOpened", { recipient: () => ({}), data: () => ({}) });
752
2143
  });
2144
+ `);
2145
+
2146
+ expect(result.errors[0]?.methodName).toBe("notification");
2147
+ });
753
2148
 
754
- describe("extractEventMigration", () => {
755
- test("captures fromVersion / toVersion / transform body", () => {
2149
+ test("emits ParseError when trigger.on is not a string/ref", () => {
756
2150
  const result = parseInline(`
757
2151
  defineFeature("f", (r) => {
758
- r.eventMigration("incidentOpened", 1, 2, (payload) => ({ ...payload, severity: "low" }));
2152
+ r.notification("incidentOpened", {
2153
+ trigger: { on: 123 },
2154
+ recipient: () => ({}),
2155
+ data: () => ({}),
2156
+ });
759
2157
  });
760
2158
  `);
761
2159
 
762
- expect(result.patterns[0]).toMatchObject({
763
- kind: "eventMigration",
764
- eventName: "incidentOpened",
765
- fromVersion: 1,
766
- toVersion: 2,
767
- });
2160
+ expect(result.errors[0]?.methodName).toBe("notification");
2161
+ });
2162
+
2163
+ test("emits ParseError when recipient is not an inline function", () => {
2164
+ const result = parseInline(`
2165
+ defineFeature("f", (r) => {
2166
+ r.notification("incidentOpened", {
2167
+ trigger: { on: "incident:create" },
2168
+ recipient: recipientRef,
2169
+ data: () => ({}),
768
2170
  });
769
2171
  });
2172
+ `);
770
2173
 
771
- describe("extractNotification", () => {
772
- test("captures trigger + recipient + data bodies", () => {
2174
+ expect(result.errors[0]?.methodName).toBe("notification");
2175
+ });
2176
+
2177
+ test("emits ParseError when data is not an inline function", () => {
773
2178
  const result = parseInline(`
774
2179
  defineFeature("f", (r) => {
775
2180
  r.notification("incidentOpened", {
776
2181
  trigger: { on: "incident:create" },
777
- recipient: (event) => ({ tenant: event.tenantId }),
778
- data: (event) => ({ id: event.id }),
2182
+ recipient: () => ({}),
2183
+ data: dataRef,
779
2184
  });
780
2185
  });
781
2186
  `);
782
2187
 
783
- expect(result.patterns[0]).toMatchObject({
784
- kind: "notification",
785
- notificationName: "incidentOpened",
786
- trigger: { on: "incident:create" },
787
- });
2188
+ expect(result.errors[0]?.methodName).toBe("notification");
788
2189
  });
789
2190
  });
790
2191
 
@@ -886,3 +2287,82 @@ defineFeature("f", (r) => {
886
2287
  });
887
2288
  });
888
2289
  });
2290
+
2291
+ // =============================================================================
2292
+ // Regression — show-pony-shaped feature (#998)
2293
+ // =============================================================================
2294
+ // Mirrors the real-world idioms from show-pony/src/features/show-pony/feature.ts
2295
+ // that motivated #998 — inline here (not the live external repo path) so this
2296
+ // test doesn't depend on the show-pony checkout being present in this repo's CI.
2297
+
2298
+ describe("show-pony-shaped feature (regression, #998)", () => {
2299
+ const result = parseInline(`
2300
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2301
+ import { eventEntity } from "./schema";
2302
+ import { eventCreateHandler } from "./handlers/event-create.write";
2303
+ import { createInviteBrandingQuery } from "./handlers/invite-branding.query";
2304
+ import { registerShowPonyScreens } from "./register/screens";
2305
+
2306
+ const someFeature = { name: "mail-foundation" };
2307
+
2308
+ defineFeature("showpony", (r) => {
2309
+ r.requires(someFeature.name, "config");
2310
+ r.entity("event", eventEntity);
2311
+ r.writeHandler(eventCreateHandler);
2312
+ r.queryHandler(createInviteBrandingQuery());
2313
+ registerShowPonyScreens(r);
2314
+ });
2315
+ `);
2316
+
2317
+ test("requires()'s computed name and entity's imported const resolve via the raw-ref sentinel (#998/#1009 fix)", () => {
2318
+ expect(result.patterns[0]).toMatchObject({
2319
+ kind: "requires",
2320
+ featureNames: [{ __raw: "someFeature.name" }, "config"],
2321
+ });
2322
+ expect(result.patterns[1]).toMatchObject({
2323
+ kind: "entity",
2324
+ entityName: "event",
2325
+ definition: { __raw: "eventEntity" },
2326
+ });
2327
+ });
2328
+
2329
+ test("writeHandler/queryHandler(handlerRef) resolve as opaque patterns (#998/#1007 fix)", () => {
2330
+ expect(result.patterns).toMatchObject([
2331
+ { kind: "requires" },
2332
+ { kind: "entity" },
2333
+ { kind: "writeHandler", handlerName: undefined },
2334
+ { kind: "queryHandler", handlerName: undefined },
2335
+ ]);
2336
+ expect(result.errors).toEqual([]);
2337
+ });
2338
+
2339
+ test("an imported registrar-wrapper stays invisible when its file can't be resolved (in-memory project, #1008)", () => {
2340
+ // parseInline uses an in-memory ts-morph Project — "./register/screens"
2341
+ // isn't a real file, so getModuleSpecifierSourceFile() returns
2342
+ // undefined and the wrapper is skipped like any other unresolvable
2343
+ // call. Real cross-file resolution against on-disk files is covered by
2344
+ // the parseFeatureFile fixture tests below.
2345
+ expect(result.patterns.some((p) => p.kind === "screen" || p.kind === "nav")).toBe(false);
2346
+ });
2347
+ });
2348
+
2349
+ describe("cross-file registrar-wrapper resolution against a real filesystem Project (#1008)", () => {
2350
+ const fixture = resolve(__dirname, "fixtures/cross-file-registrar/feature.ts");
2351
+ const result = parseFeatureFile(fixture);
2352
+
2353
+ test("resolves the imported wrapper and recognises the nav pattern it registers", () => {
2354
+ expect(result.errors).toEqual([]);
2355
+ expect(result.patterns).toMatchObject([
2356
+ { kind: "requires", featureNames: ["config"] },
2357
+ { kind: "nav", definition: { id: "foo", label: "Foo" } },
2358
+ ]);
2359
+ });
2360
+
2361
+ test("the resolved pattern's source points at the imported file, not the entry file", () => {
2362
+ const navPattern = result.patterns.find((p) => p.kind === "nav");
2363
+ expect(navPattern?.source.file).toBe(
2364
+ resolve(__dirname, "fixtures/cross-file-registrar/screens.ts"),
2365
+ );
2366
+ expect(navPattern?.source.file).not.toBe(fixture);
2367
+ });
2368
+ });