@michaelthielemann/kestrel 1.2.1

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 (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,99 @@
1
+ import { getTableConfig, SQLiteSyncDialect, type SQLiteTable } from 'drizzle-orm/sqlite-core'
2
+ import { SQL, is } from 'drizzle-orm'
3
+ import { resolveColumnName, toSnakeCase } from '../../../fields/server/field-registry/naming'
4
+ import type { CollectionDef } from '../utils/defineCollection'
5
+ import { fieldIs } from '../utils/defineCollection'
6
+ import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
7
+
8
+ // Build the *desired* schema snapshot from the Drizzle tables that collections compile to (the
9
+ // counterpart to introspect's *actual* snapshot). Reads drizzle's table metadata via getTableConfig and
10
+ // normalizes it into the same model `diffSchema` consumes (ADR-0002).
11
+
12
+ const dialect = new SQLiteSyncDialect()
13
+ const sqlText = (value: SQL) => dialect.sqlToQuery(value).sql
14
+
15
+ /** Map a Drizzle column default to its raw DDL token: a `sql\`…\`` default renders verbatim, a JS literal is quoted. */
16
+ export function defaultToken(value: unknown): string | null {
17
+ if (value === undefined || value === null) return null
18
+ if (is(value, SQL)) return sqlText(value)
19
+ if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`
20
+ if (typeof value === 'boolean') return value ? '1' : '0'
21
+ // Object / array default (a json-backed column — e.g. a custom field type): emit a quoted JSON string
22
+ // literal, matching how json/repeater columns default via sql`'{}'`. Else String({}) → "[object Object]".
23
+ if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
24
+ return String(value)
25
+ }
26
+
27
+ export function desiredTable(table: SQLiteTable, def?: CollectionDef): TableShape {
28
+ const cfg = getTableConfig(table)
29
+
30
+ // new column name -> old column name (and any single↔multiple shape transform). Two sources:
31
+ // - an explicit `renamedFrom` field hint (same field type → same naming rule for both keys);
32
+ // - a relation/media field, which maps to its opposite-multiplicity column name (`<col>` ↔ `<col>_id`)
33
+ // so toggling `multiple`/`many` renames + reshapes the data instead of dropping it. Inert unless the
34
+ // opposite column actually exists in the live DB.
35
+ const renames = new Map<string, string>()
36
+ const transforms = new Map<string, 'wrap' | 'unwrap'>()
37
+ if (def) {
38
+ for (const [key, field] of Object.entries(def.fields)) {
39
+ if (field.renamedFrom) {
40
+ renames.set(resolveColumnName(key, field).dbName, resolveColumnName(field.renamedFrom, field).dbName)
41
+ continue
42
+ }
43
+ if (fieldIs(field, 'media') || fieldIs(field, 'relation')) {
44
+ const multiple = fieldIs(field, 'media') ? field.options?.multiple === true : field.relation.many === true
45
+ const base = toSnakeCase(key)
46
+ const col = multiple ? base : `${base}_id`
47
+ renames.set(col, multiple ? `${base}_id` : base)
48
+ transforms.set(col, multiple ? 'wrap' : 'unwrap')
49
+ }
50
+ }
51
+ }
52
+
53
+ const columns: ColumnShape[] = cfg.columns.map((c) => ({
54
+ name: c.name,
55
+ type: c.getSQLType(),
56
+ notNull: c.notNull,
57
+ primaryKey: c.primary,
58
+ autoIncrement: (c as unknown as { autoIncrement?: boolean }).autoIncrement === true,
59
+ default: defaultToken(c.default),
60
+ ...(renames.has(c.name) ? { renamedFrom: renames.get(c.name) } : {}),
61
+ ...(transforms.has(c.name) ? { renameTransform: transforms.get(c.name) } : {}),
62
+ }))
63
+
64
+ const indexes: IndexShape[] = cfg.indexes.map((i) => {
65
+ const cfgI = i.config as unknown as { name: string; unique: boolean; columns: { name: string }[]; where?: SQL }
66
+ return {
67
+ name: cfgI.name,
68
+ table: cfg.name,
69
+ columns: cfgI.columns.map((x) => x.name),
70
+ unique: cfgI.unique,
71
+ where: cfgI.where ? sqlText(cfgI.where) : null,
72
+ }
73
+ })
74
+
75
+ // A column-level `.unique()` is materialized by drizzle-kit as a named unique index
76
+ // (`<table>_<column>_unique`) — surface it the same way so introspect's view and ours agree.
77
+ for (const c of cfg.columns) {
78
+ const u = c as unknown as { isUnique?: boolean; uniqueName?: string }
79
+ if (u.isUnique) {
80
+ indexes.push({ name: u.uniqueName ?? `${cfg.name}_${c.name}_unique`, table: cfg.name, columns: [c.name], unique: true, where: null })
81
+ }
82
+ }
83
+
84
+ return { name: cfg.name, columns, indexes }
85
+ }
86
+
87
+ /**
88
+ * Build a full desired snapshot from a set of Drizzle tables (registered collections + standalone tables).
89
+ * `defsByName` supplies each collection's def so `renamedFrom` field hints become column renames; tables
90
+ * without a def (e.g. `folders`) simply carry no renames.
91
+ */
92
+ export function desiredSchema(tables: SQLiteTable[], defsByName?: Map<string, CollectionDef>): SchemaSnapshot {
93
+ const snapshot: SchemaSnapshot = {}
94
+ for (const table of tables) {
95
+ const shape = desiredTable(table, defsByName?.get(getTableConfig(table).name))
96
+ snapshot[shape.name] = shape
97
+ }
98
+ return snapshot
99
+ }
@@ -0,0 +1,23 @@
1
+ import { syncSchema, describeOp, type SyncDb } from './sync'
2
+ import { sqlite, type Dialect } from './dialect'
3
+ import type { SchemaSnapshot } from './model'
4
+
5
+ /**
6
+ * The body of the dev-only schema auto-sync plugin, isolated so a failure can NEVER take down the whole
7
+ * server. syncSchema throws by design on an infeasible migration (and on any transient DDL error); if
8
+ * that escaped the Nitro plugin it would 500 every route — including the admin UI needed to diagnose it.
9
+ * Here we log loudly and let the server keep running on the previous schema. The operator-triggered
10
+ * db:migrate task is one-shot and keeps failing hard; only this always-on plugin must isolate.
11
+ */
12
+ export function runDevSchemaSync(client: SyncDb, desired: SchemaSnapshot, dialect: Dialect = sqlite): void {
13
+ try {
14
+ const { applied, skipped } = syncSchema(client, desired, {}, dialect)
15
+ if (applied.length) console.info(`[kestrel] dev schema auto-sync applied ${applied.length} statement(s)`)
16
+ // Destructive changes are never auto-applied — name them so the data loss is visible before opting in.
17
+ if (skipped.length) {
18
+ console.warn(`[kestrel] ${skipped.length} destructive schema change(s) detected — not auto-applied:\n - ${skipped.map(describeOp).join('\n - ')}\n apply with the db:migrate task ({ force: true }) — dev: GET /_nitro/tasks/db:migrate, prod: runTask('db:migrate', { payload: { force: true } })`)
19
+ }
20
+ } catch (err) {
21
+ console.error('[kestrel] dev schema auto-sync FAILED — the server keeps running on the PREVIOUS schema; fix the collection or its data and restart:\n ', err instanceof Error ? err.message : err)
22
+ }
23
+ }
@@ -0,0 +1,59 @@
1
+ import type { SchemaSnapshot, SchemaOp } from './model'
2
+ import type { IntrospectDb } from './introspect'
3
+ import { introspect } from './introspect'
4
+ import { renderSqlite } from './render-sqlite'
5
+
6
+ // The dialect seam (ADR-0002). `model.ts` + `diffSchema` are dialect-agnostic: a diff is computed as a
7
+ // `SchemaOp[]` over the normalized model, independent of any database. A `Dialect` is what binds that
8
+ // core to a concrete backend — it knows how to read the backend's live schema back into the model
9
+ // (`introspect`), how to turn ops into the backend's DDL (`render`), and how to quote an identifier.
10
+ // `sync.ts` is threaded with a Dialect (defaulting to `sqlite`), so adding a second backend means writing
11
+ // one new Dialect — not touching diff/sync. SQLite is the only implementation today; `postgres` is a
12
+ // reserved, fail-loud slot.
13
+ export interface Dialect {
14
+ /** Stable identifier, e.g. `sqlite` | `postgres`. */
15
+ readonly name: string
16
+ /** Read the live database's actual schema into the normalized model, for `diffSchema` to compare. */
17
+ introspect(db: IntrospectDb): SchemaSnapshot
18
+ /** Render structured ops into this backend's DDL statements, preserving the apply-safe order. */
19
+ render(ops: SchemaOp[]): string[]
20
+ /** Quote an identifier for this backend (backticks for SQLite, double-quotes for Postgres). */
21
+ quote(id: string): string
22
+ }
23
+
24
+ /** The SQLite dialect: the current engine, now behind the seam. `render`/`introspect` are the existing
25
+ * pure functions; `quote` is SQLite's backtick quoting (matching `render-sqlite`'s own internal escape). */
26
+ export const sqlite: Dialect = {
27
+ name: 'sqlite',
28
+ introspect,
29
+ render: renderSqlite,
30
+ quote: (id) => `\`${id.replace(/`/g, '``')}\``,
31
+ }
32
+
33
+ const pgNotImplemented = (op: string) =>
34
+ new Error(
35
+ `kestrel: the postgres dialect is not implemented yet (Dialect.${op}). It is a reserved slot ` +
36
+ `(ADR-0002) — a Postgres backend needs its own DDL renderer (real ALTER/DROP COLUMN, not SQLite's ` +
37
+ `table rebuild), information_schema introspection, and a pg-typed desired snapshot. Use \`sqlite\`.`,
38
+ )
39
+
40
+ /** Reserved Postgres slot. Every substantive entry point fails loud so a misconfiguration can never
41
+ * silently fall back to emitting SQLite DDL against a non-SQLite database. `quote` is the one piece that
42
+ * is trivially correct (standard double-quote escaping), so it is implemented rather than thrown. */
43
+ export const postgres: Dialect = {
44
+ name: 'postgres',
45
+ introspect() { throw pgNotImplemented('introspect') },
46
+ render() { throw pgNotImplemented('render') },
47
+ quote: (id) => `"${id.replace(/"/g, '""')}"`,
48
+ }
49
+
50
+ const dialects: Record<string, Dialect> = { sqlite, postgres }
51
+
52
+ /** Resolve a dialect by name (the config-driven selection point for the migration engine). Throws a
53
+ * clear error naming the supported dialects rather than silently defaulting, so a typo'd or unsupported
54
+ * backend fails at startup instead of mis-migrating. */
55
+ export function resolveDialect(name: string): Dialect {
56
+ const d = dialects[name]
57
+ if (!d) throw new Error(`kestrel: unknown database dialect "${name}" — supported: ${Object.keys(dialects).join(', ')}`)
58
+ return d
59
+ }
@@ -0,0 +1,110 @@
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
+ }
@@ -0,0 +1,67 @@
1
+ import type { ColumnShape, IndexShape, TableShape, 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) so it accepts
5
+ // any better-sqlite3 connection without importing the native module here.
6
+
7
+ interface Row { [key: string]: unknown }
8
+ export interface IntrospectDb {
9
+ prepare(sql: string): { all(...params: unknown[]): Row[] }
10
+ pragma(source: string): Row[]
11
+ }
12
+
13
+ /** Double-quote an identifier for a PRAGMA argument (names come from sqlite_master, but quote defensively). */
14
+ const dq = (id: string) => `"${id.replace(/"/g, '""')}"`
15
+
16
+ export function introspect(db: IntrospectDb): SchemaSnapshot {
17
+ // Exclude sqlite internals, the drizzle migrations ledger, and any `__kestrel_new_*` rebuild temp
18
+ // table left by an interrupted rebuild — so an orphan is never mistaken for a real (droppable) table.
19
+ const tables = db.prepare(
20
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__kestrel_new_%' AND name <> '__drizzle_migrations'",
21
+ ).all() as { name: string; sql: string | null }[]
22
+
23
+ const snapshot: SchemaSnapshot = {}
24
+ for (const { name, sql } of tables) {
25
+ snapshot[name] = { name, columns: columnsOf(db, name, sql ?? ''), indexes: indexesOf(db, name) }
26
+ }
27
+ return snapshot
28
+ }
29
+
30
+ function columnsOf(db: IntrospectDb, table: string, tableSql: string): ColumnShape[] {
31
+ // PRAGMA exposes no AUTOINCREMENT flag — it only ever applies to the INTEGER PRIMARY KEY, so detect
32
+ // it from the table's stored CREATE statement.
33
+ const hasAutoInc = /\bAUTOINCREMENT\b/i.test(tableSql)
34
+ const rows = db.pragma(`table_info(${dq(table)})`) as
35
+ { name: string; type: string; notnull: number; dflt_value: unknown; pk: number }[]
36
+ return rows.map((r) => ({
37
+ name: r.name,
38
+ type: r.type.toLowerCase(),
39
+ notNull: r.notnull === 1,
40
+ primaryKey: r.pk > 0,
41
+ autoIncrement: r.pk > 0 && hasAutoInc,
42
+ default: r.dflt_value == null ? null : String(r.dflt_value),
43
+ }))
44
+ }
45
+
46
+ function indexesOf(db: IntrospectDb, table: string): IndexShape[] {
47
+ const uniqueByName = new Map(
48
+ (db.pragma(`index_list(${dq(table)})`) as { name: string; unique: number }[]).map((m) => [m.name, m.unique === 1]),
49
+ )
50
+ // Only explicitly-created indexes carry a non-null `sql`; auto-indexes from UNIQUE/PK constraints are
51
+ // null-sql and excluded (they mirror column constraints, not declared indexes).
52
+ const rows = db.prepare(
53
+ "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL",
54
+ ).all(table) as { name: string; sql: string }[]
55
+
56
+ return rows.map(({ name, sql }) => {
57
+ const columns = (db.pragma(`index_info(${dq(name)})`) as { name: string }[]).map((c) => c.name)
58
+ const where = sql.match(/\bWHERE\b\s+(.+)$/is)
59
+ return {
60
+ name,
61
+ table,
62
+ columns,
63
+ unique: uniqueByName.get(name) ?? /\bCREATE\s+UNIQUE\b/i.test(sql),
64
+ where: where ? where[1].trim() : null,
65
+ }
66
+ })
67
+ }
@@ -0,0 +1,61 @@
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' }> }
@@ -0,0 +1,89 @@
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
+ }