@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
@@ -1,110 +0,0 @@
1
- import type { SchemaSnapshot, SchemaOp, IndexShape, ColumnShape } from './model'
2
-
3
- /** Two indexes are equivalent when uniqueness, partial predicate, and ordered columns all match. */
4
- function sameIndex(a: IndexShape, b: IndexShape): boolean {
5
- return a.unique === b.unique
6
- && a.where === b.where
7
- && a.columns.length === b.columns.length
8
- && a.columns.every((c, i) => c === b.columns[i])
9
- }
10
-
11
- /** A column's definition drifted (same name, different shape) — only fixable by a table rebuild in SQLite. */
12
- function columnDiffers(a: ColumnShape, b: ColumnShape): boolean {
13
- return a.type !== b.type || a.notNull !== b.notNull || a.primaryKey !== b.primaryKey
14
- || a.autoIncrement !== b.autoIncrement || a.default !== b.default
15
- }
16
-
17
- /**
18
- * Schema diff (ADR-0002). Additive changes (create table/column/index, drop_index) are always emitted.
19
- * Destructive changes are emitted too but kept distinct: a table only in `actual` becomes `drop_table`;
20
- * a table whose columns were dropped or whose definitions drifted becomes a `rebuild_table` (SQLite has
21
- * no in-place ALTER COLUMN). A table needing a rebuild takes the rebuild path WHOLE — its added columns
22
- * and indexes are recreated by the rebuild, not emitted separately.
23
- *
24
- * Returned in an apply-safe order: additive first (create_table → add_column → drop_index →
25
- * create_index), destructive last (rebuild_table → drop_table). The additive/destructive split lets the
26
- * apply layer gate destructive ops behind an explicit opt-in (see `isDestructive`).
27
- */
28
- export function diffSchema(desired: SchemaSnapshot, actual: SchemaSnapshot): SchemaOp[] {
29
- const createTables: SchemaOp[] = []
30
- const renames: SchemaOp[] = []
31
- const addColumns: SchemaOp[] = []
32
- const dropIndexes: SchemaOp[] = []
33
- const createIndexes: SchemaOp[] = []
34
- const rebuilds: SchemaOp[] = []
35
- const dropTables: SchemaOp[] = []
36
-
37
- for (const name of Object.keys(desired)) {
38
- const want = desired[name]
39
- const have = actual[name]
40
-
41
- if (!have) {
42
- createTables.push({ type: 'create_table', table: want })
43
- for (const index of want.indexes) createIndexes.push({ type: 'create_index', index })
44
- continue
45
- }
46
-
47
- const haveByName = new Map(have.columns.map((c) => [c.name, c]))
48
- const wantNames = new Set(want.columns.map((c) => c.name))
49
-
50
- // Resolve renames: a desired column whose `renamedFrom` exists in actual while its own name does not.
51
- // `rename_column` is additive and ordered before any rebuild, so the rest of the diff can treat the
52
- // column as already present under its NEW name (the "effective" actual view).
53
- const renameMap = new Map<string, string>() // newName -> oldName
54
- const transformMap = new Map<string, { from: string; type: 'wrap' | 'unwrap' }>() // single↔multiple toggles
55
- for (const c of want.columns) {
56
- if (c.renamedFrom && haveByName.has(c.renamedFrom) && !haveByName.has(c.name)) {
57
- renameMap.set(c.name, c.renamedFrom)
58
- if (c.renameTransform) transformMap.set(c.name, { from: c.renamedFrom, type: c.renameTransform })
59
- }
60
- }
61
- const renamedOld = new Set(renameMap.values())
62
- // A name vacated by a rename (renamedOld) reads as absent unless something else renamed INTO it —
63
- // otherwise a field re-using a just-freed name is mistaken for the column that just moved away.
64
- const effectiveHas = (n: string) => (haveByName.has(n) && !renamedOld.has(n)) || renameMap.has(n)
65
- const actualColOf = (n: string) => (renameMap.has(n) || !renamedOld.has(n)) ? haveByName.get(renameMap.get(n) ?? n) : undefined
66
-
67
- const dropped = have.columns.some((c) => !wantNames.has(c.name) && !renamedOld.has(c.name))
68
- const changed = want.columns.some((c) => { const a = actualColOf(c.name); return a !== undefined && columnDiffers(a, c) })
69
-
70
- // Plain renames are an in-place ALTER RENAME; a single↔multiple toggle reshapes the data, so it is
71
- // handled by the rebuild's transformed copy below (NO rename_column for those).
72
- for (const [to, from] of renameMap) {
73
- if (!transformMap.has(to)) renames.push({ type: 'rename_column', table: name, from, to })
74
- }
75
-
76
- if (dropped || changed || transformMap.size > 0) {
77
- // Rebuild recreates the table at `want`, copying columns present in both (by effective/new name —
78
- // plain renames already ran). A toggled column is copied from its old, differently-named source via
79
- // a shape transform. Columns dropped without a rename hint are not copied: the data loss line.
80
- const copy = want.columns.filter((c) => effectiveHas(c.name)).map((c) => c.name)
81
- const transforms = Object.fromEntries(copy.filter((c) => transformMap.has(c)).map((c) => [c, transformMap.get(c)!]))
82
- rebuilds.push({ type: 'rebuild_table', table: want, copy, ...(Object.keys(transforms).length ? { transforms } : {}) })
83
- continue
84
- }
85
-
86
- // Purely additive for this table (renames already queued above).
87
- for (const column of want.columns) {
88
- if (!effectiveHas(column.name)) addColumns.push({ type: 'add_column', table: name, column })
89
- }
90
- const haveIdx = new Map(have.indexes.map((i) => [i.name, i]))
91
- const wantIdx = new Map(want.indexes.map((i) => [i.name, i]))
92
- for (const index of want.indexes) {
93
- const existing = haveIdx.get(index.name)
94
- if (!existing) { createIndexes.push({ type: 'create_index', index }); continue }
95
- if (!sameIndex(existing, index)) {
96
- dropIndexes.push({ type: 'drop_index', index: existing })
97
- createIndexes.push({ type: 'create_index', index })
98
- }
99
- }
100
- for (const index of have.indexes) {
101
- if (!wantIdx.has(index.name)) dropIndexes.push({ type: 'drop_index', index })
102
- }
103
- }
104
-
105
- for (const name of Object.keys(actual)) {
106
- if (!desired[name]) dropTables.push({ type: 'drop_table', name })
107
- }
108
-
109
- return [...createTables, ...renames, ...addColumns, ...dropIndexes, ...createIndexes, ...rebuilds, ...dropTables]
110
- }
@@ -1,68 +0,0 @@
1
- import type { ColumnShape, IndexShape, SchemaSnapshot } from './model'
2
-
3
- // Read the *actual* schema of a live SQLite database into the normalized model, so `diffSchema` can
4
- // compare it against the desired schema (ADR-0002). Typed structurally (prepare + pragma) to keep the
5
- // native module out of this file — but the shape is narrower than better-sqlite3's own (which types
6
- // `pragma` as returning `unknown`), so a real connection only reaches these entry points through a cast.
7
-
8
- interface Row { [key: string]: unknown }
9
- export interface IntrospectDb {
10
- prepare(sql: string): { all(...params: unknown[]): Row[] }
11
- pragma(source: string): Row[]
12
- }
13
-
14
- /** Double-quote an identifier for a PRAGMA argument (names come from sqlite_master, but quote defensively). */
15
- const dq = (id: string) => `"${id.replace(/"/g, '""')}"`
16
-
17
- export function introspect(db: IntrospectDb): SchemaSnapshot {
18
- // Exclude sqlite internals, the drizzle migrations ledger, and any `__kestrel_new_*` rebuild temp
19
- // table left by an interrupted rebuild — so an orphan is never mistaken for a real (droppable) table.
20
- const tables = db.prepare(
21
- "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__kestrel_new_%' AND name <> '__drizzle_migrations'",
22
- ).all() as { name: string; sql: string | null }[]
23
-
24
- const snapshot: SchemaSnapshot = {}
25
- for (const { name, sql } of tables) {
26
- snapshot[name] = { name, columns: columnsOf(db, name, sql ?? ''), indexes: indexesOf(db, name) }
27
- }
28
- return snapshot
29
- }
30
-
31
- function columnsOf(db: IntrospectDb, table: string, tableSql: string): ColumnShape[] {
32
- // PRAGMA exposes no AUTOINCREMENT flag — it only ever applies to the INTEGER PRIMARY KEY, so detect
33
- // it from the table's stored CREATE statement.
34
- const hasAutoInc = /\bAUTOINCREMENT\b/i.test(tableSql)
35
- const rows = db.pragma(`table_info(${dq(table)})`) as
36
- { name: string; type: string; notnull: number; dflt_value: unknown; pk: number }[]
37
- return rows.map((r) => ({
38
- name: r.name,
39
- type: r.type.toLowerCase(),
40
- notNull: r.notnull === 1,
41
- primaryKey: r.pk > 0,
42
- autoIncrement: r.pk > 0 && hasAutoInc,
43
- default: r.dflt_value == null ? null : String(r.dflt_value),
44
- }))
45
- }
46
-
47
- function indexesOf(db: IntrospectDb, table: string): IndexShape[] {
48
- const uniqueByName = new Map(
49
- (db.pragma(`index_list(${dq(table)})`) as { name: string; unique: number }[]).map((m) => [m.name, m.unique === 1]),
50
- )
51
- // Only explicitly-created indexes carry a non-null `sql`; auto-indexes from UNIQUE/PK constraints are
52
- // null-sql and excluded (they mirror column constraints, not declared indexes).
53
- const rows = db.prepare(
54
- "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL",
55
- ).all(table) as { name: string; sql: string }[]
56
-
57
- return rows.map(({ name, sql }) => {
58
- const columns = (db.pragma(`index_info(${dq(name)})`) as { name: string }[]).map((c) => c.name)
59
- const where = sql.match(/\bWHERE\b\s+(.+)$/is)
60
- return {
61
- name,
62
- table,
63
- columns,
64
- unique: uniqueByName.get(name) ?? /\bCREATE\s+UNIQUE\b/i.test(sql),
65
- where: where ? where[1].trim() : null,
66
- }
67
- })
68
- }
@@ -1,61 +0,0 @@
1
- // Normalized, dialect-agnostic schema model for the migration engine (ADR-0002). Both sides of a diff
2
- // are expressed in this shape: the *desired* snapshot (built from collection tables via getTableConfig)
3
- // and the *actual* snapshot (introspected from the live DB). `diffSchema` compares them; a dialect
4
- // renderer turns the resulting ops into DDL. SQLite is the first dialect.
5
-
6
- export interface ColumnShape {
7
- name: string
8
- /** SQLite storage type as it appears in DDL: `integer` | `text` | `real` | `blob`. */
9
- type: string
10
- notNull: boolean
11
- primaryKey: boolean
12
- autoIncrement: boolean
13
- /** Raw SQL default token, exactly as it follows `DEFAULT` (e.g. `'draft'`, `0`), or null for none. */
14
- default: string | null
15
- /** The column's previous name (from a `renamedFrom` field hint) — the diff renames in place rather
16
- * than dropping the old column and losing its data. */
17
- renamedFrom?: string
18
- /** A relation/media `single↔multiple` toggle: the column moved between `<col>_id` (scalar) and `<col>`
19
- * (json array), so the rename needs a data shape transform. `wrap` = scalar→array, `unwrap` = array→scalar. */
20
- renameTransform?: 'wrap' | 'unwrap'
21
- }
22
-
23
- export interface IndexShape {
24
- name: string
25
- table: string
26
- columns: string[]
27
- unique: boolean
28
- /** Partial-index predicate (the text after `WHERE`), or null for a full index. */
29
- where: string | null
30
- }
31
-
32
- export interface TableShape {
33
- name: string
34
- columns: ColumnShape[]
35
- indexes: IndexShape[]
36
- }
37
-
38
- /** A whole schema, keyed by table name. */
39
- export type SchemaSnapshot = Record<string, TableShape>
40
-
41
- /**
42
- * One structured, dialect-agnostic schema change. The first four variants are **additive** (no data
43
- * loss): create table/column/index, and drop_index (indexes hold no row data, so dropping one is
44
- * reversible). The last two are **destructive** (see `isDestructive`):
45
- * - `drop_table` — a collection was removed.
46
- * - `rebuild_table` — a column was dropped or its definition changed; since SQLite has no in-place
47
- * ALTER COLUMN, the table is recreated and the surviving columns (`copy`) are carried over.
48
- * Destructive ops are never auto-applied; they require an explicit opt-in (ADR-0002).
49
- */
50
- export type SchemaOp =
51
- | { type: 'create_table'; table: TableShape }
52
- | { type: 'add_column'; table: string; column: ColumnShape }
53
- | { type: 'create_index'; index: IndexShape }
54
- | { type: 'drop_index'; index: IndexShape }
55
- // `rename_column` is additive (data-preserving, `ALTER TABLE … RENAME COLUMN`); it runs before any
56
- // rebuild, so a rebuilt table sees the column under its new name.
57
- | { type: 'rename_column'; table: string; from: string; to: string }
58
- | { type: 'drop_table'; name: string }
59
- // `transforms` maps a copied column to a shape transform of a differently-named source column (a
60
- // relation/media single↔multiple toggle); plain copies just use the column's own (new) name.
61
- | { type: 'rebuild_table'; table: TableShape; copy: string[]; transforms?: Record<string, { from: string; type: 'wrap' | 'unwrap' }> }
@@ -1,89 +0,0 @@
1
- import type { ColumnShape, IndexShape, TableShape, SchemaOp } from './model'
2
-
3
- // SQLite dialect renderer: structured ops → DDL strings. Kept separate from `diffSchema` (which is
4
- // dialect-agnostic) so a future Postgres renderer can sit behind the same op interface (ADR-0002).
5
-
6
- const q = (id: string) => `\`${id.replace(/`/g, '``')}\``
7
- const lit = (s: string) => `'${s.replace(/'/g, "''")}'`
8
-
9
- /**
10
- * A column definition, shared by CREATE TABLE and ALTER TABLE ADD COLUMN. Clause order matches the
11
- * committed drizzle-kit migrations: `<type> [PRIMARY KEY [AUTOINCREMENT]] [DEFAULT <x>] [NOT NULL]`.
12
- * (SQLite rejects a NOT-NULL ADD COLUMN without a default on a non-empty table — that guard belongs to
13
- * the apply/validation layer, not here.)
14
- */
15
- function columnClause(c: ColumnShape): string {
16
- let s = `${q(c.name)} ${c.type}`
17
- if (c.primaryKey) s += c.autoIncrement ? ' PRIMARY KEY AUTOINCREMENT' : ' PRIMARY KEY'
18
- if (c.default !== null) s += ` DEFAULT ${c.default}`
19
- if (c.notNull) s += ' NOT NULL'
20
- return s
21
- }
22
-
23
- // Indexes are emitted as separate create_index ops, so CREATE TABLE renders columns only.
24
- function createTableSql(name: string, columns: ColumnShape[]): string {
25
- const cols = columns.map((c) => ` ${columnClause(c)}`).join(',\n')
26
- return `CREATE TABLE ${q(name)} (\n${cols}\n);`
27
- }
28
-
29
- /**
30
- * SQLite has no ALTER COLUMN / safe DROP COLUMN, so a destructive column change is a table rebuild:
31
- * create a new table at the desired shape, copy the surviving columns' data, drop the old, rename the
32
- * new into place, then recreate the indexes. (No foreign keys exist in this schema, so the usual
33
- * `PRAGMA foreign_keys` dance is unnecessary.)
34
- */
35
- /** SELECT source for a copied column: itself, or a single↔multiple shape transform of a differently-named source. */
36
- function copySource(col: string, transforms?: Record<string, { from: string; type: 'wrap' | 'unwrap' }>): string {
37
- const t = transforms?.[col]
38
- if (!t) return q(col)
39
- return t.type === 'wrap'
40
- ? `CASE WHEN ${q(t.from)} IS NULL THEN '[]' ELSE json_array(${q(t.from)}) END`
41
- : `json_extract(${q(t.from)}, '$[0]')`
42
- }
43
-
44
- function rebuildTable(table: TableShape, copy: string[], transforms?: Record<string, { from: string; type: 'wrap' | 'unwrap' }>): string[] {
45
- const tmp = `__kestrel_new_${table.name}`
46
- // `DROP TABLE IF EXISTS` first so an orphan temp from an interrupted earlier rebuild self-heals
47
- // instead of colliding on CREATE and wedging the migration.
48
- const stmts = [`DROP TABLE IF EXISTS ${q(tmp)};`, createTableSql(tmp, table.columns)]
49
- if (copy.length) {
50
- const into = copy.map(q).join(', ')
51
- const select = copy.map((c) => copySource(c, transforms)).join(', ')
52
- stmts.push(`INSERT INTO ${q(tmp)} (${into}) SELECT ${select} FROM ${q(table.name)};`)
53
- }
54
- // Preserve the AUTOINCREMENT high-water mark across the rebuild so ids are never reused (a reused id
55
- // could silently repoint a stale internal-link / relation reference). `sqlite_sequence` has no UNIQUE
56
- // on `name`, so INSERT-OR-REPLACE won't work — ensure the temp row exists, then bump it to the old
57
- // table's mark. Must run while the old table's seq row still exists (before its DROP); the RENAME then
58
- // carries the row's name across.
59
- if (table.columns.some((c) => c.autoIncrement)) {
60
- stmts.push(`INSERT INTO sqlite_sequence (name, seq) SELECT ${lit(tmp)}, 0 WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ${lit(tmp)});`)
61
- stmts.push(`UPDATE sqlite_sequence SET seq = MAX(seq, COALESCE((SELECT seq FROM sqlite_sequence WHERE name = ${lit(table.name)}), 0)) WHERE name = ${lit(tmp)};`)
62
- }
63
- stmts.push(`DROP TABLE ${q(table.name)};`)
64
- stmts.push(`ALTER TABLE ${q(tmp)} RENAME TO ${q(table.name)};`)
65
- for (const index of table.indexes) stmts.push(createIndex(index))
66
- return stmts
67
- }
68
-
69
- function createIndex(i: IndexShape): string {
70
- const unique = i.unique ? 'UNIQUE ' : ''
71
- const cols = i.columns.map(q).join(',')
72
- const where = i.where ? ` WHERE ${i.where}` : ''
73
- return `CREATE ${unique}INDEX ${q(i.name)} ON ${q(i.table)} (${cols})${where};`
74
- }
75
-
76
- /** Render structured ops into SQLite DDL, preserving order. Most ops are one statement; a rebuild is several. */
77
- export function renderSqlite(ops: SchemaOp[]): string[] {
78
- return ops.flatMap((op): string[] => {
79
- switch (op.type) {
80
- case 'create_table': return [createTableSql(op.table.name, op.table.columns)]
81
- case 'add_column': return [`ALTER TABLE ${q(op.table)} ADD COLUMN ${columnClause(op.column)};`]
82
- case 'create_index': return [createIndex(op.index)]
83
- case 'drop_index': return [`DROP INDEX IF EXISTS ${q(op.index.name)};`]
84
- case 'rename_column': return [`ALTER TABLE ${q(op.table)} RENAME COLUMN ${q(op.from)} TO ${q(op.to)};`]
85
- case 'drop_table': return [`DROP TABLE ${q(op.name)};`]
86
- case 'rebuild_table': return rebuildTable(op.table, op.copy, op.transforms)
87
- }
88
- })
89
- }
@@ -1,201 +0,0 @@
1
- import type { IntrospectDb } from './introspect'
2
- import { diffSchema } from './diff'
3
- import { sqlite, type Dialect } from './dialect'
4
- import type { SchemaSnapshot, SchemaOp } from './model'
5
-
6
- export interface SyncDb extends IntrospectDb {
7
- exec(sql: string): unknown
8
- transaction(fn: () => void): () => void
9
- }
10
-
11
- /** Destructive ops drop or rebuild a table (potential data loss); they require an explicit opt-in (ADR-0002). */
12
- export function isDestructive(op: SchemaOp): boolean {
13
- return op.type === 'drop_table' || op.type === 'rebuild_table'
14
- }
15
-
16
- /** Human description of an op, for dry-run reports and the dev skipped-warning so data loss is visible. */
17
- export function describeOp(op: SchemaOp): string {
18
- if (op.type === 'drop_table') return `drop table \`${op.name}\``
19
- if (op.type === 'rebuild_table') {
20
- // Name the carried columns + any transform sources (not just a count), so the operator can see exactly
21
- // what survives — everything else that is live but NOT in this list has its data dropped by the rebuild.
22
- const carried = op.copy.join(', ') || '—'
23
- const sources = [...new Set(Object.values(op.transforms ?? {}).map((t) => t.from))]
24
- const from = sources.length ? ` (+ transform source(s): ${sources.join(', ')})` : ''
25
- return `rebuild table \`${op.table.name}\` — keeps: ${carried}${from}; any other live column's data is dropped`
26
- }
27
- if (op.type === 'create_table') return `create table \`${op.table.name}\` (${op.table.columns.length} column(s))`
28
- if (op.type === 'add_column') return `add column \`${op.table}\`.\`${op.column.name}\` ${op.column.type}`
29
- if (op.type === 'create_index') return `create index \`${op.index.name}\` on \`${op.index.table}\` (${op.index.columns.join(', ')})`
30
- if (op.type === 'drop_index') return `drop index \`${op.index.name}\` on \`${op.index.table}\``
31
- if (op.type === 'rename_column') return `rename column \`${op.table}\`.\`${op.from}\` to \`${op.to}\``
32
- return (op as SchemaOp).type
33
- }
34
-
35
- /**
36
- * Reasons SQLite would reject a rebuild's `INSERT…SELECT` — surfaced up-front as a clear error instead
37
- * of an opaque "NOT NULL constraint failed" inside an aborted transaction:
38
- * - a NOT NULL column with no default that isn't carried over (a new required field on a populated table)
39
- * - a carried-over column that became NOT NULL but whose existing rows still hold NULL
40
- * Identifier quoting comes from the dialect (the feasibility probes run against the live DB).
41
- */
42
- function rebuildProblems(db: SyncDb, op: Extract<SchemaOp, { type: 'rebuild_table' }>, quote: Dialect['quote'], renames: Map<string, string>): string[] {
43
- const carried = new Set(op.copy)
44
- const problems: string[] = []
45
- // The probes run against the LIVE table, which still holds the PRE-migration column names: rename_column
46
- // ops and the rebuild's INSERT…SELECT only run later, inside the transaction. So resolve each carried
47
- // column to its live SOURCE expression — a transform's `from`, else its own name — and skip when no live
48
- // source exists yet (a plain rename we can't map here), rather than probe a name that doesn't exist and
49
- // abort the whole migration with `no such column`. The expressions mirror render-sqlite's `copySource`.
50
- const liveCols = new Set((db.pragma(`table_info("${op.table.name.replace(/"/g, '""')}")`) as { name: string }[]).map((r) => r.name))
51
- const liveSourceIsNull = (c: string): boolean | null => {
52
- const t = op.transforms?.[c]
53
- if (t) {
54
- // `wrap` (single→multiple) coalesces NULL to '[]' → never NULL. `unwrap` (multiple→single) is
55
- // `json_extract(from, '$[0]')` → NULL for a NULL or empty source array.
56
- if (t.type === 'wrap') return false
57
- if (!liveCols.has(t.from)) return null
58
- return db.prepare(`SELECT 1 FROM ${quote(op.table.name)} WHERE json_extract(${quote(t.from)}, '$[0]') IS NULL LIMIT 1`).all().length > 0
59
- }
60
- if (!liveCols.has(c)) {
61
- // The column isn't live under its NEW name yet — it arrives via a plain `rename_column` that runs
62
- // first. Probe its OLD live name instead (the rename preserves data, so a NOT NULL target still
63
- // fails on rows the old column left NULL). Only skip when even the source name isn't live.
64
- const from = renames.get(`${op.table.name} ${c}`)
65
- if (from && liveCols.has(from)) return db.prepare(`SELECT 1 FROM ${quote(op.table.name)} WHERE ${quote(from)} IS NULL LIMIT 1`).all().length > 0
66
- return null
67
- }
68
- return db.prepare(`SELECT 1 FROM ${quote(op.table.name)} WHERE ${quote(c)} IS NULL LIMIT 1`).all().length > 0
69
- }
70
- for (const c of op.table.columns) {
71
- if (!c.notNull || c.default !== null || c.autoIncrement) continue
72
- if (!carried.has(c.name)) {
73
- problems.push(`${op.table.name}.${c.name}: new NOT NULL column has no default and no data to copy`)
74
- } else if (liveSourceIsNull(c.name) === true) {
75
- problems.push(`${op.table.name}.${c.name}: became NOT NULL but existing rows hold NULL`)
76
- }
77
- }
78
- // A UNIQUE index recreated by the rebuild over carried-over data that already holds duplicates would
79
- // abort the migration; flag it up front. (SQLite treats NULLs as distinct, so only non-null rows count.)
80
- // Only probe when every index column already exists live (a renamed/transformed column can't be checked
81
- // here without a live name — skip rather than crash, same as the NULL probe above).
82
- for (const idx of op.table.indexes) {
83
- if (!idx.unique || !idx.columns.every((c) => carried.has(c) && liveCols.has(c))) continue
84
- const cols = idx.columns.map(quote).join(', ')
85
- const notNull = idx.columns.map((c) => `${quote(c)} IS NOT NULL`).join(' AND ')
86
- const partial = idx.where ? ` AND (${idx.where})` : ''
87
- const dup = db.prepare(`SELECT 1 FROM ${quote(op.table.name)} WHERE ${notNull}${partial} GROUP BY ${cols} HAVING COUNT(*) > 1 LIMIT 1`).all().length > 0
88
- if (dup) problems.push(`${op.table.name}.(${idx.columns.join(', ')}): UNIQUE index "${idx.name}" has duplicate values in existing rows`)
89
- }
90
- return problems
91
- }
92
-
93
- /**
94
- * A plain `ALTER TABLE … ADD COLUMN` can't add a NOT NULL column with no default to a table that
95
- * already holds rows (SQLite rejects it; an empty table is fine). This is reachable purely additively —
96
- * adding a required field with no default to a populated collection emits a standalone `add_column`,
97
- * never a rebuild — so the rebuild pre-flight misses it. Surface it as the same up-front 'infeasible'
98
- * message instead of an opaque mid-transaction abort.
99
- */
100
- function addColumnProblems(db: SyncDb, op: Extract<SchemaOp, { type: 'add_column' }>, quote: Dialect['quote']): string[] {
101
- const c = op.column
102
- if (!c.notNull || c.default !== null || c.autoIncrement) return []
103
- const hasRows = db.prepare(`SELECT 1 FROM ${quote(op.table)} LIMIT 1`).all().length > 0
104
- return hasRows ? [`${op.table}.${c.name}: new NOT NULL column has no default and the table already has rows`] : []
105
- }
106
-
107
- /**
108
- * A new standalone UNIQUE index (e.g. flipping an existing populated column to `unique: true`) aborts if
109
- * current rows already hold duplicate non-null values. Same duplicate probe `rebuildProblems` runs, but
110
- * for a `create_index` op against the live table. Skip when an index column isn't live yet (it arrives
111
- * via a rename that runs first) rather than probe a missing column and abort with `no such column`.
112
- */
113
- function createIndexProblems(db: SyncDb, op: Extract<SchemaOp, { type: 'create_index' }>, quote: Dialect['quote']): string[] {
114
- const idx = op.index
115
- if (!idx.unique) return []
116
- const liveCols = new Set((db.pragma(`table_info("${idx.table.replace(/"/g, '""')}")`) as { name: string }[]).map((r) => r.name))
117
- if (!idx.columns.every((c) => liveCols.has(c))) return []
118
- const cols = idx.columns.map(quote).join(', ')
119
- const notNull = idx.columns.map((c) => `${quote(c)} IS NOT NULL`).join(' AND ')
120
- const partial = idx.where ? ` AND (${idx.where})` : ''
121
- const dup = db.prepare(`SELECT 1 FROM ${quote(idx.table)} WHERE ${notNull}${partial} GROUP BY ${cols} HAVING COUNT(*) > 1 LIMIT 1`).all().length > 0
122
- return dup ? [`${idx.table}.(${idx.columns.join(', ')}): UNIQUE index "${idx.name}" has duplicate values in existing rows`] : []
123
- }
124
-
125
- /** Compute the ops needed to bring `db` to `desired` WITHOUT applying them — the dry-run / `--check`
126
- * source. Reads the live schema through `dialect.introspect` (defaults to SQLite). */
127
- export function planOps(db: SyncDb, desired: SchemaSnapshot, dialect: Dialect = sqlite): SchemaOp[] {
128
- return diffSchema(desired, dialect.introspect(db))
129
- }
130
-
131
- export interface SyncOptions {
132
- /** Apply `rebuild_table` ops (a managed collection's columns were dropped or changed). */
133
- allowDestructive?: boolean
134
- /** Table names allowed to be dropped. A `drop_table` applies ONLY if its table is listed here, so an
135
- * unmanaged / unrelated table is never dropped just because `allowDestructive` was set. */
136
- dropTables?: string[]
137
- }
138
-
139
- export interface SyncResult {
140
- /** DDL statements applied, in order (empty when already in sync). */
141
- applied: string[]
142
- /** Destructive ops withheld (not opted into) — the DB was left intact for them. */
143
- skipped: SchemaOp[]
144
- }
145
-
146
- /**
147
- * Reconcile a live database to `desired` through `dialect` (defaults to SQLite). Additive ops always
148
- * apply. Destructive ops need an explicit, op-specific opt-in: `rebuild_table` requires
149
- * `allowDestructive`; `drop_table` requires the table to be named in `dropTables` (a blanket
150
- * `allowDestructive` never drops tables). Withheld ops are returned in `skipped`. Everything applied runs
151
- * in one transaction.
152
- */
153
- export function syncSchema(db: SyncDb, desired: SchemaSnapshot, opts: SyncOptions = {}, dialect: Dialect = sqlite): SyncResult {
154
- const droppable = new Set(opts.dropTables ?? [])
155
- const allowed = (op: SchemaOp): boolean => {
156
- if (op.type === 'rebuild_table') return opts.allowDestructive === true
157
- if (op.type === 'drop_table') return droppable.has(op.name)
158
- return true
159
- }
160
- const ops = planOps(db, desired, dialect)
161
- let toApply = ops.filter(allowed)
162
- let skipped = ops.filter((op) => !allowed(op))
163
-
164
- // A `rename_column` queued ahead of a `rebuild_table` is only sound as that rebuild's prologue (diff.ts
165
- // never re-gates it on the rebuild being applied). If the rebuild was withheld, ship neither half — a
166
- // bare rename would leave the table renamed but not rebuilt, silently diverging from `desired`.
167
- const withheldRebuildTables = new Set(skipped.filter((op) => op.type === 'rebuild_table').map((op) => op.table.name))
168
- if (withheldRebuildTables.size) {
169
- const demoted = toApply.filter((op) => op.type === 'rename_column' && withheldRebuildTables.has(op.table))
170
- if (demoted.length) {
171
- toApply = toApply.filter((op) => !demoted.includes(op))
172
- skipped = [...skipped, ...demoted]
173
- }
174
- }
175
-
176
- // Validate feasibility BEFORE opening the transaction, so an impossible migration fails loud and
177
- // changes nothing (rather than aborting the transaction with an opaque constraint error). Every
178
- // apply-time-fatal op is pre-flighted: rebuilds, plus the purely-additive add_column(NOT NULL, no
179
- // default) on a populated table and create_index(UNIQUE) over existing duplicates.
180
- // Plain renames (`ALTER TABLE … RENAME COLUMN`) run BEFORE the rebuilds, so a rebuild's carried column
181
- // may be probed under its OLD live name — build a PER-TABLE new→old map so the NOT NULL feasibility check
182
- // can. Keyed by table: two tables renaming a different column to the SAME new name must not collide.
183
- const renames = new Map<string, string>() // `${table} ${to}` → from
184
- for (const op of toApply) if (op.type === 'rename_column') renames.set(`${op.table} ${op.to}`, op.from)
185
- const problems = toApply.flatMap((op) =>
186
- op.type === 'rebuild_table' ? rebuildProblems(db, op, dialect.quote, renames)
187
- : op.type === 'add_column' ? addColumnProblems(db, op, dialect.quote)
188
- : op.type === 'create_index' ? createIndexProblems(db, op, dialect.quote)
189
- : [])
190
- if (problems.length) {
191
- throw new Error(`kestrel: schema migration is infeasible — fix the collection or its data, then retry:\n - ${problems.join('\n - ')}`)
192
- }
193
-
194
- const applied = dialect.render(toApply)
195
- if (applied.length) {
196
- db.transaction(() => {
197
- for (const sql of applied) db.exec(sql)
198
- })()
199
- }
200
- return { applied, skipped }
201
- }
@@ -1,6 +0,0 @@
1
- export interface Block {
2
- id: string
3
- type: string
4
- props: Record<string, unknown>
5
- slots?: Record<string, Block[]>
6
- }
@@ -1,28 +0,0 @@
1
- import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
2
- import type { z } from 'zod'
3
- import type { CollectionDef } from './defineCollection'
4
-
5
- /** The validation schemas are loose `ZodObject`s. drizzle-zod's builders are overloaded such that
6
- * `ReturnType<typeof createInsertSchema>` resolves to the WRONG (ZodEnum) overload, so the shape is stated
7
- * directly — `ZodObject` carries the `.safeParse()` / `.partial()` the CRUD layer calls; `buildCollection`
8
- * casts the builder results to it. */
9
- export type CollectionSchema = z.ZodObject<z.ZodRawShape>
10
-
11
- export interface ConditionIssue {
12
- path: (string | number)[]
13
- message: string
14
- }
15
-
16
- export interface BuiltCollection {
17
- name: string
18
- def: CollectionDef
19
- table: SQLiteTable
20
- insert: CollectionSchema
21
- update: CollectionSchema
22
- select: CollectionSchema
23
- /** Every pre-write check the per-field schema can't do because it sees one field at a time: `required`
24
- * re-enforced for conditional fields whose condition is met against the whole record, plus the
25
- * collection's own `def.validate`. Returns Zod-shaped issues keyed by the field's def name. Present
26
- * only when the collection has conditional required fields or a `validate`. */
27
- applyConditions?: (record: Record<string, unknown>) => { issues: ConditionIssue[] }
28
- }