@michaelthielemann/kestrel 3.0.0 → 4.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 (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -0,0 +1,163 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
7
+ const VITEST_BIN = path.join(ROOT, 'node_modules/.bin/vitest')
8
+ const PARTS_DIR = path.join(ROOT, 'reports/coverage/.parts')
9
+ const OUT_DIR = path.join(ROOT, 'reports/coverage')
10
+
11
+ function sameKeys(a, b) {
12
+ const ka = Object.keys(a)
13
+ const kb = Object.keys(b)
14
+ if (ka.length !== kb.length) return false
15
+ const setB = new Set(kb)
16
+ return ka.every((k) => setB.has(k))
17
+ }
18
+
19
+ function mapsAgree(a, b) {
20
+ return sameKeys(a.statementMap, b.statementMap) && sameKeys(a.fnMap, b.fnMap) && sameKeys(a.branchMap, b.branchMap)
21
+ }
22
+
23
+ function countExecutedFunctions(entry) {
24
+ return Object.values(entry.f).filter((n) => n > 0).length
25
+ }
26
+
27
+ function cloneEntry(entry) {
28
+ return structuredClone(entry)
29
+ }
30
+
31
+ function sumCounters(a, b) {
32
+ const out = {}
33
+ for (const k of Object.keys(a)) out[k] = a[k] + (b[k] ?? 0)
34
+ return out
35
+ }
36
+
37
+ function sumBranches(a, b) {
38
+ const out = {}
39
+ for (const k of Object.keys(a)) {
40
+ const branchA = a[k]
41
+ const branchB = b[k] ?? []
42
+ out[k] = branchA.map((count, i) => count + (branchB[i] ?? 0))
43
+ }
44
+ return out
45
+ }
46
+
47
+ // Istanbul JSON is keyed by absolute file path; the same path can show up in several parts
48
+ // (a package run and the root run both touching a shared file). When their instrumentation
49
+ // maps agree, counters are additive. When they disagree, the maps describe different
50
+ // transforms of the file — summing them would misattribute counts to the wrong statements/
51
+ // functions, so the richer entry wins outright and the conflict is reported instead of hidden.
52
+ export function mergeCoverage(parts, conflicts = []) {
53
+ const merged = {}
54
+ for (const part of parts) {
55
+ for (const [filePath, entry] of Object.entries(part)) {
56
+ const existing = merged[filePath]
57
+ if (!existing) {
58
+ merged[filePath] = cloneEntry(entry)
59
+ continue
60
+ }
61
+ if (mapsAgree(existing, entry)) {
62
+ merged[filePath] = {
63
+ ...existing,
64
+ s: sumCounters(existing.s, entry.s),
65
+ f: sumCounters(existing.f, entry.f),
66
+ b: sumBranches(existing.b, entry.b),
67
+ }
68
+ } else {
69
+ if (countExecutedFunctions(entry) > countExecutedFunctions(existing)) {
70
+ merged[filePath] = cloneEntry(entry)
71
+ }
72
+ conflicts.push(filePath)
73
+ }
74
+ }
75
+ }
76
+ return merged
77
+ }
78
+
79
+ function readCoverage(dir) {
80
+ const file = path.join(dir, 'coverage-final.json')
81
+ if (!fs.existsSync(file)) return null
82
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
83
+ }
84
+
85
+ function countFiles(coverage) {
86
+ return coverage ? Object.keys(coverage).length : 0
87
+ }
88
+
89
+ function countFunctionsTotal(coverage) {
90
+ if (!coverage) return 0
91
+ let total = 0
92
+ for (const entry of Object.values(coverage)) total += countExecutedFunctions(entry)
93
+ return total
94
+ }
95
+
96
+ function discoverPackageSuites() {
97
+ const packagesDir = path.join(ROOT, 'packages')
98
+ if (!fs.existsSync(packagesDir)) return []
99
+ return fs
100
+ .readdirSync(packagesDir)
101
+ .filter((name) => fs.existsSync(path.join(packagesDir, name, 'vitest.config.ts')))
102
+ .sort()
103
+ .map((name) => ({ name, cwd: path.join(packagesDir, name) }))
104
+ }
105
+
106
+ function runSuite(name, cwd) {
107
+ const reportsDirectory = path.join(PARTS_DIR, name)
108
+ fs.mkdirSync(reportsDirectory, { recursive: true })
109
+ const args = [
110
+ 'run',
111
+ '--coverage.enabled',
112
+ '--coverage.provider=v8',
113
+ '--coverage.reporter=json',
114
+ `--coverage.reportsDirectory=${reportsDirectory}`,
115
+ '--coverage.reportOnFailure=true',
116
+ ]
117
+ console.log(`\n=== ${name} (${path.relative(ROOT, cwd) || '.'}) ===`)
118
+ const result = spawnSync(VITEST_BIN, args, { cwd, stdio: 'inherit' })
119
+ const passed = result.status === 0
120
+ const coverage = readCoverage(reportsDirectory)
121
+ return { name, passed, coverage }
122
+ }
123
+
124
+ export function main() {
125
+ fs.rmSync(PARTS_DIR, { recursive: true, force: true })
126
+ fs.mkdirSync(PARTS_DIR, { recursive: true })
127
+
128
+ const suites = [{ name: 'root', cwd: ROOT }, ...discoverPackageSuites()]
129
+ const results = suites.map(({ name, cwd }) => runSuite(name, cwd))
130
+
131
+ const conflicts = []
132
+ const merged = mergeCoverage(
133
+ results.map((r) => r.coverage).filter(Boolean),
134
+ conflicts,
135
+ )
136
+
137
+ fs.mkdirSync(OUT_DIR, { recursive: true })
138
+ fs.writeFileSync(path.join(OUT_DIR, 'coverage-final.json'), JSON.stringify(merged))
139
+
140
+ console.log('\n=== coverage-all summary ===')
141
+ for (const r of results) {
142
+ const status = r.passed ? 'PASS' : 'FAIL'
143
+ console.log(
144
+ `${r.name.padEnd(24)} ${status} files=${countFiles(r.coverage)} functionsExecuted=${countFunctionsTotal(r.coverage)}`,
145
+ )
146
+ }
147
+
148
+ if (conflicts.length > 0) {
149
+ console.log(`\n${conflicts.length} merge conflict(s) (kept the entry with more executed functions):`)
150
+ for (const filePath of conflicts) console.log(` ${filePath}`)
151
+ } else {
152
+ console.log('\nno merge conflicts')
153
+ }
154
+
155
+ console.log(
156
+ `\nmerged: files=${Object.keys(merged).length} functionsExecuted=${countFunctionsTotal(merged)} -> ${path.join(OUT_DIR, 'coverage-final.json')}`,
157
+ )
158
+
159
+ const anyFailed = results.some((r) => !r.passed)
160
+ process.exitCode = anyFailed ? 1 : 0
161
+ }
162
+
163
+ if (import.meta.url === `file://${process.argv[1]}`) main()
@@ -0,0 +1,89 @@
1
+ // Deterministic static dashboard generator — no network. Boots the pipeline/collection registries
2
+ // the same way test/architecture/pipeline-invariants.test.ts does (no Nuxt boot needed for that), then
3
+ // renders through the SAME renderDashboard() the dev route uses, so the two surfaces never drift.
4
+ //
5
+ // Run with: npx tsx scripts/dashboard.mjs (or `pnpm dashboard`)
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { join, dirname } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import Database from 'better-sqlite3'
11
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
12
+ import { fieldTypes } from '@michaelthielemann/kestrel-fields'
13
+ import {
14
+ buildIntrospectionPipelines, buildPipelineIndex, clearPipelines, clearRegistry,
15
+ registerCollection, registerPipeline, renderDashboard, setResolvedKestrelConfig, allCollections,
16
+ } from '@michaelthielemann/kestrel-core'
17
+ import { buildAuthPipelines } from '@michaelthielemann/kestrel-auth'
18
+ import { pagesCollection } from '@michaelthielemann/kestrel-collections'
19
+ import { mediaCollection, mediaSettingsCollection } from '@michaelthielemann/kestrel-media'
20
+ import { redirectsCollection, siteCollection } from '@michaelthielemann/kestrel-publishing'
21
+ import { resolveServerKestrelConfig } from '../layers/core/server/utils/server-config.ts'
22
+ import { PLUGIN_ORDER } from '../layers/core/modules/plugin-order/plugin-order.ts'
23
+ import posts from '../server/collections/posts.ts'
24
+ import settings from '../server/collections/settings.ts'
25
+
26
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
27
+
28
+ void fieldTypes
29
+ setResolvedKestrelConfig(resolveServerKestrelConfig())
30
+ Object.assign(globalThis, {
31
+ useRuntimeConfig: () => ({
32
+ kestrel: { output: { driver: 'local', dir: '', publicDir: '/kestrel-no-such-public-dir', auto: true, publishOnSave: false, reconcileMinutes: 0, verbose: false, s3: {} } },
33
+ }),
34
+ useDb: () => drizzle(new Database(':memory:')),
35
+ primaryLocale: () => 'en',
36
+ prefixPrimaryLocale: () => false,
37
+ })
38
+
39
+ clearRegistry()
40
+ for (const c of [pagesCollection, posts, settings, mediaCollection, mediaSettingsCollection, redirectsCollection, siteCollection]) registerCollection(c)
41
+
42
+ clearPipelines()
43
+ for (const def of buildAuthPipelines()) registerPipeline(def)
44
+ for (const def of buildIntrospectionPipelines()) registerPipeline(def)
45
+
46
+ const collections = allCollections().map((c) => ({
47
+ name: c.name,
48
+ mode: c.def.mode,
49
+ translatable: Boolean(c.def.translatable),
50
+ fieldCount: Object.keys(c.def.fields).length,
51
+ }))
52
+
53
+ /** Reads a repo-only enrichment file, or returns undefined — a consumer project (or a fresh checkout
54
+ * before `graphify update`/`pnpm api:check` have run) ships none of these, so every read is optional. */
55
+ function readJsonIfExists(relPath) {
56
+ const abs = join(repoRoot, relPath)
57
+ if (!existsSync(abs)) return undefined
58
+ return JSON.parse(readFileSync(abs, 'utf8'))
59
+ }
60
+
61
+ function apiSurfaceSection() {
62
+ const ceilings = readJsonIfExists('test/architecture/public-api-ceilings.json')
63
+ if (!ceilings) return undefined
64
+ const out = []
65
+ for (const [report, ceiling] of Object.entries(ceilings)) {
66
+ const abs = join(repoRoot, report)
67
+ if (!existsSync(abs)) continue
68
+ const count = readFileSync(abs, 'utf8').split('\n').filter((l) => /^\/\/ @(public|alpha|beta|internal)\b/.test(l)).length
69
+ out.push({ report, count, ceiling })
70
+ }
71
+ return out
72
+ }
73
+
74
+ const edgeAllowlist = readJsonIfExists('test/architecture/edge-allowlist.json')
75
+ const apiSurface = apiSurfaceSection()
76
+
77
+ const html = renderDashboard({
78
+ pipelines: buildPipelineIndex(),
79
+ pluginOrder: PLUGIN_ORDER.map((p) => ({ layer: p.layer, file: p.file, after: p.after })),
80
+ collections,
81
+ graph: (edgeAllowlist || apiSurface) ? { packageEdges: edgeAllowlist?.edges, apiSurface } : undefined,
82
+ generatedAt: new Date().toISOString(),
83
+ })
84
+
85
+ const outDir = join(repoRoot, 'docs')
86
+ mkdirSync(outDir, { recursive: true })
87
+ const outFile = join(outDir, 'dashboard.html')
88
+ writeFileSync(outFile, html)
89
+ console.log(`[dashboard] wrote ${outFile} (${buildPipelineIndex().length} pipelines, ${collections.length} collections)`)
@@ -3,7 +3,7 @@ import { hashPassword } from './lib/password.mjs'
3
3
  import { Cancelled, promptPassword } from './lib/cli.mjs'
4
4
 
5
5
  // Not used at runtime: this prints a value an operator pastes into KESTREL_ADMIN_PASSWORD_HASH. Kept as a
6
- // standalone entry point because docs/consuming-kestrel.md tells consumers to run it straight out of
6
+ // standalone entry point because docs/guide/getting-started.md tells consumers to run it straight out of
7
7
  // node_modules, which must keep working whether or not the `kestrel` bin is on PATH.
8
8
  const arg = process.argv[2]
9
9
  if (arg) {
@@ -0,0 +1,51 @@
1
+ // Indexed view over an Istanbul-shaped coverage report, for the audit detectors.
2
+ //
3
+ // Two questions only: which branch arms were never taken, and which functions never ran. Both are
4
+ // keyed by line so they can be joined to the graph, whose nodes carry a start line and no span.
5
+ import { readFileSync, statSync } from 'node:fs'
6
+ import { join, relative, isAbsolute } from 'node:path'
7
+
8
+ export function loadCoverage(root, file = 'reports/coverage/coverage-final.json') {
9
+ const path = isAbsolute(file) ? file : join(root, file)
10
+ let raw
11
+ let generatedAt
12
+ try {
13
+ raw = JSON.parse(readFileSync(path, 'utf8'))
14
+ generatedAt = statSync(path).mtimeMs
15
+ } catch {
16
+ return null
17
+ }
18
+
19
+ const byFile = new Map()
20
+ for (const entry of Object.values(raw)) {
21
+ const rel = relative(root, entry.path)
22
+ const missedBranchLines = new Set()
23
+ let branchMissCount = 0
24
+ for (const [key, arm] of Object.entries(entry.branchMap ?? {})) {
25
+ const counts = entry.b?.[key] ?? []
26
+ arm.locations?.forEach((location, index) => {
27
+ if (counts[index] !== 0) return
28
+ branchMissCount += 1
29
+ const line = location.start?.line ?? arm.line
30
+ if (line) missedBranchLines.add(line)
31
+ })
32
+ }
33
+ const ranFunctionLines = new Set()
34
+ for (const [key, fn] of Object.entries(entry.fnMap ?? {})) {
35
+ if (!entry.f?.[key]) continue
36
+ const line = fn.decl?.start?.line ?? fn.loc?.start?.line
37
+ if (line) ranFunctionLines.add(line)
38
+ }
39
+ byFile.set(rel, { missedBranchLines, branchMissCount, ranFunctionLines })
40
+ }
41
+
42
+ const empty = { missedBranchLines: new Set(), branchMissCount: 0, ranFunctionLines: new Set() }
43
+ return {
44
+ generatedAt,
45
+ files: [...byFile.keys()],
46
+ has: (file) => byFile.has(file),
47
+ missedBranchLines: (file) => (byFile.get(file) ?? empty).missedBranchLines,
48
+ branchMissCount: (file) => (byFile.get(file) ?? empty).branchMissCount,
49
+ ranFunctionLines: (file) => (byFile.get(file) ?? empty).ranFunctionLines,
50
+ }
51
+ }
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ // Port-graph extraction (experiment): for every exported function in packages/kestrel-core/src whose
3
+ // return type resolves to `Effect.Effect<A, E, R>`, the R (requirements) channel becomes that function's
4
+ // port list. Every `Layer.succeed`/`Layer.effect`/`Layer.scoped` call becomes an adapter binding from the
5
+ // layer's containing function to the port it provides. Nodes reuse graphify-out/graph.json's id scheme so
6
+ // the two graphs join; a node not already present there is synthesized with the same scheme and flagged.
7
+ //
8
+ // The R channel alone cannot answer "does persist always follow validate" — this codebase passes the
9
+ // database as a plain function parameter, not a Context.Tag requirement, so R is `never` on every step.
10
+ // To make that invariant askable as a graph query at all, this script also extracts each `definePipeline`
11
+ // call's literal `steps: [...]` array (resolving each step-factory call to the runtime step name registered
12
+ // via `syncStep(name, ...)`) into an ordered step chain, and exposes `stepOrderRespectsInvariant` for
13
+ // querying it.
14
+ import ts from 'typescript'
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
16
+ import { dirname, join, relative } from 'node:path'
17
+ import { fileURLToPath, pathToFileURL } from 'node:url'
18
+
19
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
20
+ const corePkg = join(root, 'packages/kestrel-core')
21
+ const graphOutDir = join(root, 'graphify-out')
22
+
23
+ // --- graph id scheme (mirrors graphify's own slugging, see graphify-out/graph.json) ---
24
+
25
+ function slugFile(relFile) {
26
+ return relFile.replace(/\.[^/.]+$/, '').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()
27
+ }
28
+
29
+ function slugSymbol(name) {
30
+ return name.replace(/\(\)$/, '').toLowerCase().replace(/[^a-z0-9]/g, '')
31
+ }
32
+
33
+ function loadExistingGraph() {
34
+ const graphPath = join(graphOutDir, 'graph.json')
35
+ if (!existsSync(graphPath)) return { nodes: [], byFileAndLabel: new Map() }
36
+ const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
37
+ const byFileAndLabel = new Map()
38
+ for (const n of graph.nodes) byFileAndLabel.set(`${n.source_file}::${n.label}`, n)
39
+ return { nodes: graph.nodes, byFileAndLabel }
40
+ }
41
+
42
+ /** Reuses the existing graph's node id for (relFile, name) when present; otherwise synthesizes one with
43
+ * the same scheme and marks it so a future graphify run can be checked against it. */
44
+ function nodeIdFor(existing, relFile, name) {
45
+ const found = existing.byFileAndLabel.get(`${relFile}::${name}()`)
46
+ if (found) return { id: found.id, synthesized: false }
47
+ return { id: `${slugFile(relFile)}_${slugSymbol(name)}`, synthesized: true }
48
+ }
49
+
50
+ function loadProgram() {
51
+ const tsconfigPath = join(corePkg, 'tsconfig.json')
52
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile)
53
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, corePkg)
54
+ const fileNames = parsed.fileNames.filter((f) => !/\.test\.ts$/.test(f) && !f.includes(`${corePkg}/test/`))
55
+ const program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true })
56
+ return { program, fileNames }
57
+ }
58
+
59
+ function hasExportModifier(node) {
60
+ return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0
61
+ }
62
+
63
+ function isEffectReturnType(type) {
64
+ const target = type.aliasSymbol ?? type.target?.symbol ?? type.symbol
65
+ return target?.name === 'Effect' && !!(type).typeArguments
66
+ }
67
+
68
+ /** Flattens a union of `Context.Tag`-shaped types (or a bare one) into distinct display names, dropping
69
+ * `never` (the honest "no requirements" answer, not an omission). */
70
+ function portNamesFromRType(checker, rType) {
71
+ if (!rType) return []
72
+ const parts = rType.isUnion?.() ? rType.types : [rType]
73
+ const names = new Set()
74
+ for (const t of parts) {
75
+ const text = checker.typeToString(t)
76
+ if (text === 'never' || text === 'unknown') continue
77
+ names.add(text)
78
+ }
79
+ return [...names]
80
+ }
81
+
82
+ /** Visits every exported function-like declaration (function declarations and `export const f = (...) =>`)
83
+ * in a source file, at the top level and one level of nesting (namespaces are not used in this codebase). */
84
+ function* exportedFunctions(sf) {
85
+ for (const stmt of sf.statements) {
86
+ if (ts.isFunctionDeclaration(stmt) && hasExportModifier(stmt) && stmt.name) {
87
+ yield { name: stmt.name.text, decl: stmt }
88
+ } else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
89
+ for (const d of stmt.declarationList.declarations) {
90
+ if (ts.isIdentifier(d.name) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
91
+ yield { name: d.name.text, decl: d.initializer }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ function extractFunctionPorts(program, existing) {
99
+ const checker = program.getTypeChecker()
100
+ const functions = []
101
+ let scanned = 0
102
+ for (const sf of program.getSourceFiles()) {
103
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
104
+ const relFile = relative(root, sf.fileName)
105
+ for (const { name, decl } of exportedFunctions(sf)) {
106
+ scanned++
107
+ const sig = checker.getSignatureFromDeclaration(decl)
108
+ if (!sig) continue
109
+ const returnType = checker.getReturnTypeOfSignature(sig)
110
+ if (!isEffectReturnType(returnType)) continue
111
+ const typeArgs = checker.getTypeArguments(returnType)
112
+ const rType = typeArgs[2]
113
+ const ports = portNamesFromRType(checker, rType)
114
+ const { id, synthesized } = nodeIdFor(existing, relFile, name)
115
+ functions.push({ id, name, file: relFile, ports, synthesized })
116
+ }
117
+ }
118
+ return { functions, scanned }
119
+ }
120
+
121
+ function containingFunctionName(node) {
122
+ let cur = node.parent
123
+ while (cur) {
124
+ if ((ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur) || ts.isArrowFunction(cur)) ) {
125
+ if (ts.isFunctionDeclaration(cur) && cur.name) return cur.name.text
126
+ if (ts.isVariableDeclaration(cur.parent) && ts.isIdentifier(cur.parent.name)) return cur.parent.name.text
127
+ }
128
+ cur = cur.parent
129
+ }
130
+ return null
131
+ }
132
+
133
+ /** Resolves a `Context.GenericTag<X>(...)` (or `Context.Tag<X, ...>`) expression, following one level of
134
+ * identifier indirection, to the service type name `X`. */
135
+ function resolveTagTypeName(checker, expr) {
136
+ let target = expr
137
+ if (ts.isIdentifier(expr)) {
138
+ const sym = checker.getSymbolAtLocation(expr)
139
+ const decl = sym?.valueDeclaration
140
+ if (decl && ts.isVariableDeclaration(decl) && decl.initializer) target = decl.initializer
141
+ }
142
+ if (ts.isCallExpression(target) && target.typeArguments?.length) {
143
+ return target.typeArguments[0].getText()
144
+ }
145
+ return null
146
+ }
147
+
148
+ const LAYER_CONSTRUCTORS = new Set(['succeed', 'effect', 'scoped', 'sync'])
149
+
150
+ function extractLayerBindings(program, existing) {
151
+ const checker = program.getTypeChecker()
152
+ const bindings = []
153
+ for (const sf of program.getSourceFiles()) {
154
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
155
+ const relFile = relative(root, sf.fileName)
156
+ const visit = (node) => {
157
+ if (
158
+ ts.isCallExpression(node)
159
+ && ts.isPropertyAccessExpression(node.expression)
160
+ && ts.isIdentifier(node.expression.expression)
161
+ && node.expression.expression.text === 'Layer'
162
+ && LAYER_CONSTRUCTORS.has(node.expression.name.text)
163
+ && node.arguments.length > 0
164
+ ) {
165
+ const tagName = resolveTagTypeName(checker, node.arguments[0])
166
+ if (tagName) {
167
+ const fnName = containingFunctionName(node) ?? '<module scope>'
168
+ const { id, synthesized } = nodeIdFor(existing, relFile, fnName)
169
+ bindings.push({
170
+ layerFnId: id,
171
+ layerFnName: fnName,
172
+ file: relFile,
173
+ line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
174
+ constructor: node.expression.name.text,
175
+ port: tagName,
176
+ synthesized,
177
+ })
178
+ }
179
+ }
180
+ ts.forEachChild(node, visit)
181
+ }
182
+ visit(sf)
183
+ }
184
+ return bindings
185
+ }
186
+
187
+ // --- Pipeline step-order extraction (makes the validate-before-persist invariant askable as a graph query) ---
188
+
189
+ /** Maps every step-factory function name (e.g. `validateCreateStep`) to the runtime step name it registers
190
+ * via `syncStep('<name>', ...)` / `asyncStep('<name>', ...)`. A factory with more than one such call inside
191
+ * keeps only the first — every factory in this codebase registers exactly one step. */
192
+ function buildStepNameMap(program) {
193
+ const map = new Map()
194
+ for (const sf of program.getSourceFiles()) {
195
+ if (!sf.fileName.includes('/pipeline/steps/') || sf.fileName.includes('/node_modules/')) continue
196
+ for (const { name, decl } of exportedFunctions(sf)) {
197
+ let stepName = null
198
+ const visit = (node) => {
199
+ if (stepName) return
200
+ if (
201
+ ts.isCallExpression(node)
202
+ && ts.isIdentifier(node.expression)
203
+ && (node.expression.text === 'syncStep' || node.expression.text === 'asyncStep')
204
+ && node.arguments[0] && ts.isStringLiteral(node.arguments[0])
205
+ ) {
206
+ stepName = node.arguments[0].text
207
+ return
208
+ }
209
+ ts.forEachChild(node, visit)
210
+ }
211
+ visit(decl)
212
+ if (stepName) map.set(name, stepName)
213
+ }
214
+ }
215
+ return map
216
+ }
217
+
218
+ function extractPipelineStepChains(program) {
219
+ const stepNameMap = buildStepNameMap(program)
220
+ const pipelines = []
221
+ for (const sf of program.getSourceFiles()) {
222
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
223
+ const relFile = relative(root, sf.fileName)
224
+ const visit = (node) => {
225
+ if (
226
+ ts.isCallExpression(node)
227
+ && ts.isIdentifier(node.expression)
228
+ && node.expression.text === 'definePipeline'
229
+ && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])
230
+ ) {
231
+ const obj = node.arguments[0]
232
+ const nameProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'name')
233
+ const stepsProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'steps')
234
+ if (nameProp && ts.isStringLiteral(nameProp.initializer) && stepsProp && ts.isArrayLiteralExpression(stepsProp.initializer)) {
235
+ const steps = []
236
+ for (const el of stepsProp.initializer.elements) {
237
+ // `steps: deleteSteps` (an identifier referring to a separately declared array) is skipped —
238
+ // rollback/duplicate/delete are not in VALIDATING_WRITE_OPS, so the invariant query never needs it.
239
+ if (ts.isCallExpression(el) && ts.isIdentifier(el.expression)) {
240
+ const factoryName = el.expression.text
241
+ steps.push(stepNameMap.get(factoryName) ?? factoryName)
242
+ } else if (ts.isCallExpression(el) && ts.isPropertyAccessExpression(el.expression)) {
243
+ steps.push(el.expression.name.text)
244
+ }
245
+ }
246
+ pipelines.push({ name: nameProp.initializer.text, file: relFile, line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1, steps })
247
+ }
248
+ }
249
+ ts.forEachChild(node, visit)
250
+ }
251
+ visit(sf)
252
+ }
253
+ return pipelines
254
+ }
255
+
256
+ /** The validate-before-persist invariant, as a pure query over an ordered step-name chain: `validate` must occur, `persist`
257
+ * must occur, and `validate` must precede `persist`. Mirrors test/architecture/pipeline-invariants.test.ts'
258
+ * registry-based check, but over the statically extracted `definePipeline` step array instead of a
259
+ * runtime-composed `PipelineDescriptor`. */
260
+ export function stepOrderRespectsInvariant(stepNames) {
261
+ const validateAt = stepNames.indexOf('validate')
262
+ const persistAt = stepNames.indexOf('persist')
263
+ if (validateAt < 0 || persistAt < 0) return false
264
+ return validateAt < persistAt
265
+ }
266
+
267
+ export const VALIDATING_WRITE_OPS = new Set(['createOne', 'createMany', 'updateOne', 'updateMany'])
268
+
269
+ export function extractPortGraph() {
270
+ const { program } = loadProgram()
271
+ const existing = loadExistingGraph()
272
+ const { functions, scanned } = extractFunctionPorts(program, existing)
273
+ const bindings = extractLayerBindings(program, existing)
274
+ const pipelines = extractPipelineStepChains(program)
275
+ return { functions, scanned, bindings, pipelines }
276
+ }
277
+
278
+ function buildEmittedGraph({ functions, bindings, pipelines }) {
279
+ const nodes = []
280
+ const links = []
281
+ const seenNodes = new Set()
282
+ const addNode = (node) => {
283
+ if (seenNodes.has(node.id)) return
284
+ seenNodes.add(node.id)
285
+ nodes.push(node)
286
+ }
287
+
288
+ for (const fn of functions) {
289
+ addNode({ id: fn.id, label: `${fn.name}()`, kind: 'function', source_file: fn.file, synthesized: fn.synthesized })
290
+ for (const port of fn.ports) {
291
+ const portId = `port_${slugSymbol(port)}`
292
+ addNode({ id: portId, label: port, kind: 'port' })
293
+ links.push({ relation: 'requires', source: fn.id, target: portId, source_file: fn.file })
294
+ }
295
+ }
296
+
297
+ for (const b of bindings) {
298
+ addNode({ id: b.layerFnId, label: `${b.layerFnName}()`, kind: 'function', source_file: b.file, synthesized: b.synthesized })
299
+ const portId = `port_${slugSymbol(b.port)}`
300
+ addNode({ id: portId, label: b.port, kind: 'port' })
301
+ links.push({ relation: 'provides', source: b.layerFnId, target: portId, source_file: b.file, source_location: `L${b.line}`, layer_constructor: b.constructor })
302
+ }
303
+
304
+ for (const [i, p] of pipelines.entries()) {
305
+ const pipelineId = `pipeline_${slugFile(p.file)}_${slugSymbol(p.name)}_${i}`
306
+ addNode({ id: pipelineId, label: `${p.name} (pipeline)`, kind: 'pipeline', source_file: p.file, source_location: `L${p.line}` })
307
+ p.steps.forEach((stepName, order) => {
308
+ const stepId = `pipeline_step_${slugSymbol(stepName)}`
309
+ addNode({ id: stepId, label: stepName, kind: 'pipeline_step' })
310
+ links.push({ relation: 'pipeline_step', source: pipelineId, target: stepId, order, source_file: p.file })
311
+ })
312
+ }
313
+
314
+ return { nodes, links }
315
+ }
316
+
317
+ function main() {
318
+ const { functions, scanned, bindings, pipelines } = extractPortGraph()
319
+ const nonEmpty = functions.filter((f) => f.ports.length > 0)
320
+ const totalPorts = new Set(functions.flatMap((f) => f.ports)).size
321
+
322
+ const emitted = buildEmittedGraph({ functions, bindings, pipelines })
323
+ const reportDir = join(root, 'reports/graph')
324
+ mkdirSync(reportDir, { recursive: true })
325
+ const outPath = join(reportDir, 'port-graph.json')
326
+ writeFileSync(outPath, JSON.stringify({ generated_at: new Date().toISOString(), nodes: emitted.nodes, links: emitted.links }, null, 2))
327
+
328
+ console.log(`port-graph: scanned ${scanned} exported functions in packages/kestrel-core/src`)
329
+ console.log(`port-graph: ${functions.length} have an Effect.Effect<A, E, R> return type`)
330
+ console.log(`port-graph: ${nonEmpty.length} have a non-empty (non-never) R channel; ${totalPorts} distinct ports found`)
331
+ console.log(`port-graph: ${bindings.length} Layer composition(s) -> adapter binding(s) found`)
332
+ console.log(`port-graph: ${pipelines.length} definePipeline() call(s) with a literal steps array extracted`)
333
+ console.log(`port-graph: wrote ${emitted.nodes.length} nodes, ${emitted.links.length} links to ${relative(root, outPath)}`)
334
+
335
+ if (nonEmpty.length === 0) {
336
+ console.log('port-graph: honest finding — every scanned function has R = never (DB/services are plain')
337
+ console.log('port-graph: parameters here, not Context.Tag requirements), so the R-channel port list is')
338
+ console.log('port-graph: empty by construction. The pipeline_step chains above make the validate-before-persist')
339
+ console.log('port-graph: invariant askable as a graph query at all; see test/architecture/port-graph-cross-check.test.ts.')
340
+ }
341
+
342
+ for (const p of pipelines) {
343
+ if (!VALIDATING_WRITE_OPS.has(p.name)) continue
344
+ const ok = stepOrderRespectsInvariant(p.steps)
345
+ console.log(`port-graph: pipeline "${p.name}" (${p.file}:L${p.line}) validate-before-persist = ${ok}`)
346
+ }
347
+ }
348
+
349
+ const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url
350
+ if (isMain) main()
@@ -44,6 +44,17 @@ pass('server (Nitro) project', 'tsc', ['--noEmit', '-p', gateConfig])
44
44
  // 3. App / `.vue` / modules / config — vue-tsc via `nuxt typecheck` (needs the committed root tsconfig.json).
45
45
  pass('app + .vue (vue-tsc)', 'nuxt', ['typecheck'])
46
46
 
47
+ // 4. Standalone workspace packages have their own tsconfig, outside the playground project. Globbed by
48
+ // pnpm's own filter (topological order, every current and future `@michaelthielemann/kestrel-*` package, `create-kestrel`
49
+ // excluded by name) rather than a hand-written list per package — the class of drift a hand list invites
50
+ // (a package silently missing from this one script) is the exact failure mode this rail exists to avoid.
51
+ console.log('› typechecking packages (@michaelthielemann/kestrel-*)…')
52
+ try {
53
+ execFileSync('pnpm', ['--filter', '@michaelthielemann/kestrel-*', '-r', 'typecheck'], { cwd: root, stdio: 'inherit', env })
54
+ } catch {
55
+ failed.push('packages (@michaelthielemann/kestrel-*)')
56
+ }
57
+
47
58
  if (failed.length) {
48
59
  console.error(`\n✗ typecheck failed: ${failed.join(', ')}`)
49
60
  process.exit(1)