@ekrist1/vulse 0.1.6-alpha.3 → 0.1.7-alpha.4

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 (264) hide show
  1. package/dist/cli/migrate.d.ts.map +1 -1
  2. package/dist/cli/migrate.js +3 -4
  3. package/dist/cli/setup.d.ts.map +1 -1
  4. package/dist/cli/setup.js +11 -10
  5. package/dist/core/blueprints/compile.d.ts +1 -1
  6. package/dist/core/blueprints/compile.d.ts.map +1 -1
  7. package/dist/core/blueprints/compile.js +3 -3
  8. package/dist/core/blueprints/mutations.js +2 -2
  9. package/dist/core/forms/rate-limit.d.ts +1 -1
  10. package/dist/core/forms/rate-limit.d.ts.map +1 -1
  11. package/dist/core/forms/rate-limit.js +3 -3
  12. package/dist/core/forms/unique.d.ts +1 -1
  13. package/dist/core/forms/unique.d.ts.map +1 -1
  14. package/dist/core/forms/unique.js +4 -4
  15. package/dist/core/globals/compile.d.ts +1 -1
  16. package/dist/core/globals/compile.d.ts.map +1 -1
  17. package/dist/core/globals/compile.js +2 -2
  18. package/dist/core/globals/definition.d.ts +1 -1
  19. package/dist/core/globals/definition.d.ts.map +1 -1
  20. package/dist/core/globals/definition.js +3 -3
  21. package/dist/core/repos/globals.js +3 -3
  22. package/dist/core/sha256.d.ts +3 -0
  23. package/dist/core/sha256.d.ts.map +1 -0
  24. package/dist/core/sha256.js +9 -0
  25. package/dist/integration/index.js +1 -1
  26. package/dist/integration/install-hook.d.ts.map +1 -1
  27. package/dist/integration/install-hook.js +6 -4
  28. package/dist/integration/wrangler-config.d.ts +12 -0
  29. package/dist/integration/wrangler-config.d.ts.map +1 -0
  30. package/dist/integration/wrangler-config.js +97 -0
  31. package/dist/integration/wrangler-patch.d.ts +1 -0
  32. package/dist/integration/wrangler-patch.d.ts.map +1 -1
  33. package/dist/integration/wrangler-patch.js +2 -1
  34. package/dist/server/routes/form-submit.js +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +11 -3
  38. package/src/admin/assets/logo-mark.svg +5 -0
  39. package/src/admin/client/active-locale.ts +17 -0
  40. package/src/admin/client/api.ts +21 -0
  41. package/src/admin/client/form-from-zod.ts +7 -0
  42. package/src/admin/client/live-preview-enabled.ts +5 -0
  43. package/src/admin/components/AdminShell.astro +45 -0
  44. package/src/admin/components/AuthSettings.vue +60 -0
  45. package/src/admin/components/BlockEditor.vue +53 -0
  46. package/src/admin/components/BlueprintEditor.vue +1783 -0
  47. package/src/admin/components/CollectionKindIcon.vue +26 -0
  48. package/src/admin/components/CollectionTree.vue +220 -0
  49. package/src/admin/components/EntryEditorWithPreview.vue +130 -0
  50. package/src/admin/components/EntryForm.vue +411 -0
  51. package/src/admin/components/EntryList.vue +121 -0
  52. package/src/admin/components/EntryStatusBadge.vue +24 -0
  53. package/src/admin/components/FormEditor.vue +233 -0
  54. package/src/admin/components/FormList.vue +54 -0
  55. package/src/admin/components/GlobalSetEditor.vue +272 -0
  56. package/src/admin/components/GlobalSetList.vue +55 -0
  57. package/src/admin/components/LivePreviewPanel.vue +171 -0
  58. package/src/admin/components/LoginForm.vue +53 -0
  59. package/src/admin/components/MediaLibrary.vue +106 -0
  60. package/src/admin/components/MediaPicker.vue +49 -0
  61. package/src/admin/components/RevisionDiff.vue +11 -0
  62. package/src/admin/components/RevisionList.vue +134 -0
  63. package/src/admin/components/SeoFields.vue +113 -0
  64. package/src/admin/components/SetEditor.vue +137 -0
  65. package/src/admin/components/SetList.vue +32 -0
  66. package/src/admin/components/SettingsForm.vue +189 -0
  67. package/src/admin/components/SideNav.vue +152 -0
  68. package/src/admin/components/SubmissionDetail.vue +45 -0
  69. package/src/admin/components/SubmissionList.vue +89 -0
  70. package/src/admin/components/ToastHost.vue +33 -0
  71. package/src/admin/components/TreeRow.vue +163 -0
  72. package/src/admin/components/UserEditor.vue +186 -0
  73. package/src/admin/components/UserList.vue +46 -0
  74. package/src/admin/components/blocks/BlockItem.vue +32 -0
  75. package/src/admin/components/blocks/BlockToolbar.vue +12 -0
  76. package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
  77. package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
  78. package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
  79. package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
  80. package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
  81. package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
  82. package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
  83. package/src/admin/components/fields/BlocksField.vue +123 -0
  84. package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
  85. package/src/admin/components/fields/BoolField.vue +10 -0
  86. package/src/admin/components/fields/DateField.vue +22 -0
  87. package/src/admin/components/fields/EntriesField.vue +153 -0
  88. package/src/admin/components/fields/EntryField.vue +138 -0
  89. package/src/admin/components/fields/EnumField.vue +81 -0
  90. package/src/admin/components/fields/FieldRenderer.vue +87 -0
  91. package/src/admin/components/fields/GridField.vue +173 -0
  92. package/src/admin/components/fields/LinkField.vue +219 -0
  93. package/src/admin/components/fields/MediaField.vue +69 -0
  94. package/src/admin/components/fields/NumberField.vue +12 -0
  95. package/src/admin/components/fields/ObjectField.vue +18 -0
  96. package/src/admin/components/fields/RefField.vue +170 -0
  97. package/src/admin/components/fields/RepeaterField.vue +27 -0
  98. package/src/admin/components/fields/ReplicatorField.vue +121 -0
  99. package/src/admin/components/fields/TextField.vue +11 -0
  100. package/src/admin/components/fields/TextareaField.vue +11 -0
  101. package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
  102. package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
  103. package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
  104. package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
  105. package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
  106. package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
  107. package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
  108. package/src/admin/components/fields/emoji-extension.ts +54 -0
  109. package/src/admin/components/fields/link-extension.ts +48 -0
  110. package/src/admin/components/fields/set-node-utils.ts +115 -0
  111. package/src/admin/components/fields/url-utils.ts +85 -0
  112. package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
  113. package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
  114. package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
  115. package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
  116. package/src/admin/components/fields/vulse-set-extension.ts +66 -0
  117. package/src/admin/components/fields/vulse-video-extension.ts +65 -0
  118. package/src/admin/composables/toast.ts +35 -0
  119. package/src/admin/composables/useEntrySearch.ts +112 -0
  120. package/src/admin/composables/useSets.ts +31 -0
  121. package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
  122. package/src/admin/pages/collections/[name]/[id].astro +90 -0
  123. package/src/admin/pages/collections/[name]/index.astro +31 -0
  124. package/src/admin/pages/collections/[name]/new.astro +38 -0
  125. package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
  126. package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
  127. package/src/admin/pages/forms/[handle].astro +9 -0
  128. package/src/admin/pages/forms/index.astro +7 -0
  129. package/src/admin/pages/forms/new.astro +7 -0
  130. package/src/admin/pages/index.astro +36 -0
  131. package/src/admin/pages/login.astro +14 -0
  132. package/src/admin/pages/media.astro +8 -0
  133. package/src/admin/pages/schema/[handle].astro +10 -0
  134. package/src/admin/pages/schema/new.astro +9 -0
  135. package/src/admin/pages/settings/auth.astro +9 -0
  136. package/src/admin/pages/settings/globals/[handle].astro +9 -0
  137. package/src/admin/pages/settings/globals/index.astro +7 -0
  138. package/src/admin/pages/settings/globals/new.astro +7 -0
  139. package/src/admin/pages/settings/index.astro +8 -0
  140. package/src/admin/pages/settings/sets/[handle].astro +9 -0
  141. package/src/admin/pages/settings/sets/index.astro +7 -0
  142. package/src/admin/pages/settings/sets/new.astro +7 -0
  143. package/src/admin/pages/users/[id].astro +10 -0
  144. package/src/admin/pages/users/index.astro +8 -0
  145. package/src/admin/styles/admin.css +166 -0
  146. package/src/core/access.ts +9 -0
  147. package/src/core/blocks/schema.ts +66 -0
  148. package/src/core/blueprints/code-to-definition.ts +156 -0
  149. package/src/core/blueprints/compile.ts +176 -0
  150. package/src/core/blueprints/define.ts +12 -0
  151. package/src/core/blueprints/definition.ts +185 -0
  152. package/src/core/blueprints/load.ts +144 -0
  153. package/src/core/blueprints/mutations.ts +236 -0
  154. package/src/core/blueprints/preview-path.ts +33 -0
  155. package/src/core/blueprints/reflect-fields.ts +305 -0
  156. package/src/core/blueprints/registry.ts +14 -0
  157. package/src/core/blueprints/seed.ts +20 -0
  158. package/src/core/blueprints/select-helpers.ts +30 -0
  159. package/src/core/blueprints/seo.ts +180 -0
  160. package/src/core/blueprints/types.ts +59 -0
  161. package/src/core/blueprints/zod-helpers.ts +86 -0
  162. package/src/core/db.ts +11 -0
  163. package/src/core/errors.ts +34 -0
  164. package/src/core/forms/compile.ts +84 -0
  165. package/src/core/forms/definition.ts +102 -0
  166. package/src/core/forms/rate-limit.ts +52 -0
  167. package/src/core/forms/unique.ts +38 -0
  168. package/src/core/globals/compile.ts +35 -0
  169. package/src/core/globals/definition.ts +27 -0
  170. package/src/core/locales.ts +45 -0
  171. package/src/core/migrations.ts +48 -0
  172. package/src/core/parse-content.ts +85 -0
  173. package/src/core/plugins/definition.ts +150 -0
  174. package/src/core/preview-content.ts +21 -0
  175. package/src/core/repos/entries.ts +504 -0
  176. package/src/core/repos/forms.ts +270 -0
  177. package/src/core/repos/globals.ts +179 -0
  178. package/src/core/repos/media.ts +106 -0
  179. package/src/core/repos/preview-sessions.ts +108 -0
  180. package/src/core/repos/revisions.ts +60 -0
  181. package/src/core/repos/settings.ts +23 -0
  182. package/src/core/schema.ts +244 -0
  183. package/src/core/sets/compile.ts +12 -0
  184. package/src/core/sets/definition.ts +10 -0
  185. package/src/core/sets/service.ts +82 -0
  186. package/src/core/sets/validate-tree.ts +57 -0
  187. package/src/core/sha256.ts +10 -0
  188. package/src/core/slug.ts +30 -0
  189. package/src/scaffold/collection-write.ts +83 -0
  190. package/src/scaffold/collection.ts +277 -0
  191. package/src/server/assets/live-preview-bridge.content.ts +2 -0
  192. package/src/server/assets/live-preview-bridge.js +535 -0
  193. package/src/server/better-auth.ts +82 -0
  194. package/src/server/cf-images.ts +34 -0
  195. package/src/server/cron.ts +37 -0
  196. package/src/server/email.ts +17 -0
  197. package/src/server/endpoints/api-auth.ts +10 -0
  198. package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
  199. package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
  200. package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
  201. package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
  202. package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
  203. package/src/server/endpoints/api-vulse-entries.ts +23 -0
  204. package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
  205. package/src/server/endpoints/api-vulse-form-public.ts +7 -0
  206. package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
  207. package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
  208. package/src/server/endpoints/api-vulse-forms.ts +12 -0
  209. package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
  210. package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
  211. package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
  212. package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
  213. package/src/server/endpoints/api-vulse-globals.ts +12 -0
  214. package/src/server/endpoints/api-vulse-media-file.ts +7 -0
  215. package/src/server/endpoints/api-vulse-media-id.ts +12 -0
  216. package/src/server/endpoints/api-vulse-media.ts +12 -0
  217. package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
  218. package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
  219. package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
  220. package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
  221. package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
  222. package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
  223. package/src/server/endpoints/api-vulse-revisions.ts +7 -0
  224. package/src/server/endpoints/api-vulse-search.ts +7 -0
  225. package/src/server/endpoints/api-vulse-sets.ts +23 -0
  226. package/src/server/endpoints/api-vulse-settings.ts +12 -0
  227. package/src/server/endpoints/api-vulse-users-id.ts +12 -0
  228. package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
  229. package/src/server/endpoints/api-vulse-users-role.ts +9 -0
  230. package/src/server/endpoints/api-vulse-users.ts +7 -0
  231. package/src/server/endpoints/with-runtime.ts +11 -0
  232. package/src/server/env.ts +23 -0
  233. package/src/server/envelope.ts +21 -0
  234. package/src/server/forms/email.ts +11 -0
  235. package/src/server/forms/process-submission.ts +95 -0
  236. package/src/server/forms/queue.ts +25 -0
  237. package/src/server/forms/templates.ts +24 -0
  238. package/src/server/forms/webhook.ts +19 -0
  239. package/src/server/handler.ts +66 -0
  240. package/src/server/image-probe.ts +35 -0
  241. package/src/server/loader.ts +54 -0
  242. package/src/server/plugins.ts +214 -0
  243. package/src/server/preview.ts +25 -0
  244. package/src/server/r2.ts +13 -0
  245. package/src/server/routes/blueprints.ts +62 -0
  246. package/src/server/routes/entries.ts +255 -0
  247. package/src/server/routes/form-submit.ts +168 -0
  248. package/src/server/routes/form-upload.ts +100 -0
  249. package/src/server/routes/forms.ts +88 -0
  250. package/src/server/routes/globals-public.ts +30 -0
  251. package/src/server/routes/globals.ts +93 -0
  252. package/src/server/routes/media.ts +145 -0
  253. package/src/server/routes/preview-sessions.ts +76 -0
  254. package/src/server/routes/preview.ts +36 -0
  255. package/src/server/routes/revisions.ts +29 -0
  256. package/src/server/routes/search.ts +31 -0
  257. package/src/server/routes/sets.ts +40 -0
  258. package/src/server/routes/settings.ts +24 -0
  259. package/src/server/routes/users.ts +127 -0
  260. package/src/server/runtime.ts +99 -0
  261. package/src/server/sdk/collections.ts +98 -0
  262. package/src/server/sdk/index.ts +25 -0
  263. package/src/server/sdk/media.ts +11 -0
  264. package/src/server/sdk/search.ts +90 -0
@@ -0,0 +1,150 @@
1
+ import type { FormDefinition } from '../forms/definition.js'
2
+ import type { SubmissionRow } from '../repos/forms.js'
3
+ import type { Role } from '../blueprints/types.js'
4
+
5
+ export type MaybePromise<T> = T | Promise<T>
6
+
7
+ export interface VulsePluginLogger {
8
+ debug(message: string, data?: unknown): void
9
+ info(message: string, data?: unknown): void
10
+ warn(message: string, data?: unknown): void
11
+ error(message: string, data?: unknown): void
12
+ }
13
+
14
+ export interface VulsePluginEmail {
15
+ send(input: {
16
+ to: string
17
+ subject: string
18
+ text?: string
19
+ body?: string
20
+ html?: string
21
+ }): Promise<void>
22
+ }
23
+
24
+ export interface VulsePluginContext {
25
+ env: Record<string, unknown>
26
+ logger: VulsePluginLogger
27
+ email: VulsePluginEmail
28
+ }
29
+
30
+ export interface FormBeforeSubmitEvent {
31
+ request: Request
32
+ form: FormDefinition
33
+ payload: Record<string, unknown>
34
+ ip: string
35
+ headers: Headers
36
+ }
37
+
38
+ export type FormBeforeSubmitResult =
39
+ | void
40
+ | {
41
+ action?: 'continue'
42
+ payload?: Record<string, unknown>
43
+ }
44
+ | {
45
+ action: 'drop'
46
+ reason?: string
47
+ response?: 'fake-success'
48
+ }
49
+ | {
50
+ action: 'reject'
51
+ message?: string
52
+ }
53
+
54
+ export interface FormAfterSubmitEvent {
55
+ request: Request
56
+ form: FormDefinition
57
+ payload: Record<string, unknown>
58
+ submission: SubmissionRow
59
+ ip: string
60
+ headers: Headers
61
+ }
62
+
63
+ export interface FormProcessEvent {
64
+ form: FormDefinition
65
+ payload: Record<string, unknown>
66
+ submission: SubmissionRow
67
+ }
68
+
69
+ export interface AuthUserCreateInput {
70
+ id?: string
71
+ email?: string
72
+ name?: string
73
+ role?: Role
74
+ displayName?: string | null
75
+ [key: string]: unknown
76
+ }
77
+
78
+ export interface AuthUserCreateEvent {
79
+ user: AuthUserCreateInput
80
+ }
81
+
82
+ export type AuthUserBeforeCreateResult =
83
+ | void
84
+ | false
85
+ | {
86
+ action?: 'continue'
87
+ data?: AuthUserCreateInput
88
+ }
89
+ | {
90
+ action: 'reject'
91
+ message?: string
92
+ }
93
+
94
+ export interface AuthUserCreatedEvent {
95
+ user: AuthUserCreateInput
96
+ }
97
+
98
+ export interface VulsePluginHooks {
99
+ 'form:beforeSubmit'?: (
100
+ event: FormBeforeSubmitEvent,
101
+ ctx: VulsePluginContext
102
+ ) => MaybePromise<FormBeforeSubmitResult>
103
+ 'form:afterSubmit'?: (
104
+ event: FormAfterSubmitEvent,
105
+ ctx: VulsePluginContext
106
+ ) => MaybePromise<void>
107
+ 'form:beforeProcess'?: (
108
+ event: FormProcessEvent,
109
+ ctx: VulsePluginContext
110
+ ) => MaybePromise<void>
111
+ 'form:afterProcess'?: (
112
+ event: FormProcessEvent,
113
+ ctx: VulsePluginContext
114
+ ) => MaybePromise<void>
115
+ 'auth:userBeforeCreate'?: (
116
+ event: AuthUserCreateEvent,
117
+ ctx: VulsePluginContext
118
+ ) => MaybePromise<AuthUserBeforeCreateResult>
119
+ 'auth:userAfterCreate'?: (
120
+ event: AuthUserCreatedEvent,
121
+ ctx: VulsePluginContext
122
+ ) => MaybePromise<void>
123
+ }
124
+
125
+ export type VulseHookName = keyof VulsePluginHooks
126
+
127
+ export interface VulsePlugin {
128
+ id: string
129
+ version?: string
130
+ /**
131
+ * Higher priority plugins run earlier. Plugins with the same priority run
132
+ * in registration order.
133
+ */
134
+ priority?: number
135
+ capabilities?: string[]
136
+ hooks?: VulsePluginHooks
137
+ }
138
+
139
+ const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9._-]*$/
140
+
141
+ export function assertValidPluginId(id: string): void {
142
+ if (!PLUGIN_ID_RE.test(id)) {
143
+ throw new Error(`Vulse plugin "${id}": id must be lowercase letters, numbers, dots, underscores, or dashes`)
144
+ }
145
+ }
146
+
147
+ export function definePlugin<const T extends VulsePlugin>(plugin: T): T {
148
+ assertValidPluginId(plugin.id)
149
+ return plugin
150
+ }
@@ -0,0 +1,21 @@
1
+ export interface VulseLivePreviewLocals {
2
+ entryId: string | null
3
+ collection: string
4
+ slug: string
5
+ content: unknown
6
+ }
7
+
8
+ export interface PreviewLocals {
9
+ vulseLivePreview?: VulseLivePreviewLocals | null
10
+ vulsePreview?: boolean
11
+ }
12
+
13
+ export function resolvePreviewContent(
14
+ entry: { id: string; content: unknown; draftContent?: unknown | null } | null,
15
+ locals: PreviewLocals,
16
+ ): unknown | null {
17
+ const live = locals.vulseLivePreview
18
+ if (live && entry && live.entryId === entry.id) return live.content
19
+ if (locals.vulsePreview && entry?.draftContent != null) return entry.draftContent
20
+ return entry?.content ?? null
21
+ }
@@ -0,0 +1,504 @@
1
+ import { and, asc, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import type { VulseDb } from '../db.js'
4
+ import { entries, entryLocales, entryRevisions } from '../schema.js'
5
+ import { NotFoundError, ValidationError } from '../errors.js'
6
+ import { isValidSlug, normalizeSlug } from '../slug.js'
7
+
8
+ export const DEFAULT_LOCALE = 'default'
9
+
10
+ export interface EntryRow {
11
+ id: string
12
+ collection: string
13
+ parentId: string | null
14
+ sortOrder: number
15
+ slug: string
16
+ status: 'draft' | 'published'
17
+ locale: string
18
+ version: number
19
+ content: unknown
20
+ draftContent: unknown | null
21
+ hasUnpublishedChanges: boolean
22
+ publishedAt: Date | null
23
+ createdAt: Date
24
+ updatedAt: Date
25
+ createdBy: string | null
26
+ updatedBy: string | null
27
+ }
28
+
29
+ export interface EntryNode extends EntryRow {
30
+ children: EntryNode[]
31
+ }
32
+
33
+ export interface EntryLocaleSummary {
34
+ locale: string
35
+ slug: string
36
+ status: 'draft' | 'published'
37
+ hasUnpublishedChanges: boolean
38
+ publishedAt: Date | null
39
+ updatedAt: Date
40
+ }
41
+
42
+ export type EntryOrderBy = 'sortOrder' | 'publishedAt' | 'updatedAt' | 'createdAt'
43
+
44
+ export interface ListOptions {
45
+ collection: string
46
+ locale?: string
47
+ status?: 'draft' | 'published'
48
+ parentId?: string | null
49
+ limit?: number
50
+ offset?: number
51
+ createdBy?: string
52
+ publishedAfter?: Date
53
+ publishedBefore?: Date
54
+ orderBy?: EntryOrderBy
55
+ order?: 'asc' | 'desc'
56
+ }
57
+
58
+ type EntryShell = typeof entries.$inferSelect
59
+ type EntryLocale = typeof entryLocales.$inferSelect
60
+
61
+ function joinToEntry(shell: EntryShell, locale: EntryLocale): EntryRow {
62
+ return {
63
+ id: shell.id,
64
+ collection: shell.collection,
65
+ parentId: shell.parentId ?? null,
66
+ sortOrder: shell.sortOrder,
67
+ slug: locale.slug,
68
+ status: locale.status,
69
+ locale: locale.locale,
70
+ version: locale.version,
71
+ content: locale.content,
72
+ draftContent: locale.draftContent ?? null,
73
+ hasUnpublishedChanges: locale.draftContent != null,
74
+ publishedAt: locale.publishedAt,
75
+ createdAt: shell.createdAt,
76
+ updatedAt: locale.updatedAt,
77
+ createdBy: shell.createdBy,
78
+ updatedBy: locale.updatedBy,
79
+ }
80
+ }
81
+
82
+ export class EntriesRepo {
83
+ constructor(private db: VulseDb) {}
84
+
85
+ private async resolveUniqueSlug(
86
+ collection: string,
87
+ locale: string,
88
+ desired: string,
89
+ excludeEntryId?: string,
90
+ ): Promise<string> {
91
+ const base = normalizeSlug(desired)
92
+ if (!base || !isValidSlug(base)) {
93
+ throw new ValidationError('URL slug must use lowercase letters, numbers, and hyphens only.', {
94
+ field: 'slug',
95
+ issues: [{ path: ['slug'], message: 'Use lowercase letters, numbers, and hyphens only.' }],
96
+ })
97
+ }
98
+
99
+ let suffix = 1
100
+ for (;;) {
101
+ const candidate = suffix === 1 ? base : `${base}-${suffix}`
102
+ const [existing] = await this.db.select({ entryId: entryLocales.entryId })
103
+ .from(entryLocales)
104
+ .where(and(
105
+ eq(entryLocales.collection, collection),
106
+ eq(entryLocales.locale, locale),
107
+ eq(entryLocales.slug, candidate),
108
+ ))
109
+ .limit(1)
110
+ if (!existing || existing.entryId === excludeEntryId) return candidate
111
+ suffix++
112
+ }
113
+ }
114
+
115
+ private isSlugUniqueViolation(err: unknown): boolean {
116
+ const message = err instanceof Error ? err.message : String(err)
117
+ return message.includes('UNIQUE constraint failed') && message.includes('slug')
118
+ }
119
+
120
+ async maxSortOrder(collection: string, parentId: string | null): Promise<number> {
121
+ const conds = [eq(entries.collection, collection)]
122
+ conds.push(parentId ? eq(entries.parentId, parentId) : isNull(entries.parentId))
123
+ const [row] = await this.db.select({ max: sql<number>`coalesce(max(${entries.sortOrder}), 0)` })
124
+ .from(entries)
125
+ .where(and(...conds))
126
+ return row?.max ?? 0
127
+ }
128
+
129
+ /** True if the proposed parent would create a cycle (parent is a descendant of id). */
130
+ private async wouldCreateCycle(id: string, proposedParentId: string | null): Promise<boolean> {
131
+ if (proposedParentId === null) return false
132
+ if (proposedParentId === id) return true
133
+ // Walk parent chain upward from proposedParentId; if we hit `id`, it's a cycle.
134
+ const seen = new Set<string>()
135
+ let current: string | null = proposedParentId
136
+ while (current) {
137
+ if (seen.has(current)) return false // pre-existing cycle, but not caused by this move
138
+ seen.add(current)
139
+ if (current === id) return true
140
+ const [row] = await this.db.select({ parentId: entries.parentId })
141
+ .from(entries).where(eq(entries.id, current)).limit(1)
142
+ current = row?.parentId ?? null
143
+ }
144
+ return false
145
+ }
146
+
147
+ async create(input: {
148
+ collection: string
149
+ slug: string
150
+ content: unknown
151
+ createdBy: string
152
+ status?: 'draft' | 'published'
153
+ locale?: string
154
+ parentId?: string | null
155
+ draftsEnabled?: boolean
156
+ }): Promise<EntryRow> {
157
+ const locale = input.locale ?? DEFAULT_LOCALE
158
+ const slug = await this.resolveUniqueSlug(input.collection, locale, input.slug)
159
+ const now = new Date()
160
+ const sortOrder = (await this.maxSortOrder(input.collection, input.parentId ?? null)) + 1
161
+ const publishNow = !input.draftsEnabled && (input.status ?? 'draft') === 'published'
162
+
163
+ const entryId = nanoid()
164
+ const shellRow = {
165
+ id: entryId,
166
+ collection: input.collection,
167
+ parentId: input.parentId ?? null,
168
+ sortOrder,
169
+ createdAt: now,
170
+ updatedAt: now,
171
+ createdBy: input.createdBy,
172
+ }
173
+ const localeRow = {
174
+ entryId,
175
+ collection: input.collection,
176
+ locale,
177
+ slug,
178
+ status: publishNow ? 'published' as const : (input.status ?? 'draft'),
179
+ version: 1,
180
+ content: input.draftsEnabled && !publishNow ? {} : input.content,
181
+ draftContent: input.draftsEnabled && !publishNow ? input.content : null,
182
+ publishedAt: publishNow ? now : null,
183
+ updatedAt: now,
184
+ updatedBy: input.createdBy,
185
+ }
186
+
187
+ const insertStmts = () => [
188
+ this.db.insert(entries).values(shellRow),
189
+ this.db.insert(entryLocales).values(localeRow),
190
+ this.db.insert(entryRevisions).values({
191
+ id: nanoid(),
192
+ entryId,
193
+ locale,
194
+ version: 1,
195
+ content: input.content,
196
+ authorId: input.createdBy,
197
+ changeSummary: null,
198
+ createdAt: now,
199
+ }),
200
+ ] as const
201
+
202
+ try {
203
+ const [a, b, c] = insertStmts()
204
+ await this.db.batch([a, b, c])
205
+ } catch (err) {
206
+ if (!this.isSlugUniqueViolation(err)) throw err
207
+ localeRow.slug = await this.resolveUniqueSlug(input.collection, locale, slug)
208
+ const [a, b, c] = insertStmts()
209
+ await this.db.batch([a, b, c])
210
+ }
211
+
212
+ return joinToEntry(shellRow as EntryShell, localeRow as EntryLocale)
213
+ }
214
+
215
+ /** Add a new locale translation to an existing entry. */
216
+ async createLocale(entryId: string, input: {
217
+ locale: string
218
+ slug: string
219
+ content: unknown
220
+ updatedBy: string
221
+ status?: 'draft' | 'published'
222
+ draftsEnabled?: boolean
223
+ }): Promise<EntryRow> {
224
+ const shell = await this.findShellById(entryId)
225
+ if (!shell) throw new NotFoundError(`Entry ${entryId} not found`)
226
+ const slug = await this.resolveUniqueSlug(shell.collection, input.locale, input.slug)
227
+ const now = new Date()
228
+ const publishNow = !input.draftsEnabled && (input.status ?? 'draft') === 'published'
229
+ const localeRow = {
230
+ entryId,
231
+ collection: shell.collection,
232
+ locale: input.locale,
233
+ slug,
234
+ status: publishNow ? 'published' as const : (input.status ?? 'draft'),
235
+ version: 1,
236
+ content: input.draftsEnabled && !publishNow ? {} : input.content,
237
+ draftContent: input.draftsEnabled && !publishNow ? input.content : null,
238
+ publishedAt: publishNow ? now : null,
239
+ updatedAt: now,
240
+ updatedBy: input.updatedBy,
241
+ }
242
+ await this.db.batch([
243
+ this.db.insert(entryLocales).values(localeRow),
244
+ this.db.insert(entryRevisions).values({
245
+ id: nanoid(),
246
+ entryId,
247
+ locale: input.locale,
248
+ version: 1,
249
+ content: input.content,
250
+ authorId: input.updatedBy,
251
+ changeSummary: null,
252
+ createdAt: now,
253
+ }),
254
+ this.db.update(entries).set({ updatedAt: now }).where(eq(entries.id, entryId)),
255
+ ])
256
+ return joinToEntry({ ...shell, updatedAt: now }, localeRow as EntryLocale)
257
+ }
258
+
259
+ async findShellById(id: string): Promise<EntryShell | null> {
260
+ const [row] = await this.db.select().from(entries).where(eq(entries.id, id)).limit(1)
261
+ return row ?? null
262
+ }
263
+
264
+ async findById(id: string, locale: string = DEFAULT_LOCALE): Promise<EntryRow | null> {
265
+ const [shell] = await this.db.select().from(entries).where(eq(entries.id, id)).limit(1)
266
+ if (!shell) return null
267
+ const [loc] = await this.db.select().from(entryLocales)
268
+ .where(and(eq(entryLocales.entryId, id), eq(entryLocales.locale, locale)))
269
+ .limit(1)
270
+ if (!loc) return null
271
+ return joinToEntry(shell, loc)
272
+ }
273
+
274
+ /** Returns every locale row for an entry — used by the admin to render the locale picker. */
275
+ async listLocales(id: string): Promise<EntryLocaleSummary[]> {
276
+ const rows = await this.db.select({
277
+ locale: entryLocales.locale,
278
+ slug: entryLocales.slug,
279
+ status: entryLocales.status,
280
+ draftContent: entryLocales.draftContent,
281
+ publishedAt: entryLocales.publishedAt,
282
+ updatedAt: entryLocales.updatedAt,
283
+ }).from(entryLocales).where(eq(entryLocales.entryId, id))
284
+ return rows.map((r) => ({
285
+ locale: r.locale,
286
+ slug: r.slug,
287
+ status: r.status,
288
+ hasUnpublishedChanges: r.draftContent != null,
289
+ publishedAt: r.publishedAt,
290
+ updatedAt: r.updatedAt,
291
+ }))
292
+ }
293
+
294
+ async findBySlug(collection: string, slug: string, locale: string = DEFAULT_LOCALE): Promise<EntryRow | null> {
295
+ const [loc] = await this.db.select().from(entryLocales).where(
296
+ and(
297
+ eq(entryLocales.collection, collection),
298
+ eq(entryLocales.slug, slug),
299
+ eq(entryLocales.locale, locale),
300
+ ),
301
+ ).limit(1)
302
+ if (!loc) return null
303
+ const [shell] = await this.db.select().from(entries).where(eq(entries.id, loc.entryId)).limit(1)
304
+ if (!shell) return null
305
+ return joinToEntry(shell, loc)
306
+ }
307
+
308
+ async list(opts: ListOptions): Promise<EntryRow[]> {
309
+ const locale = opts.locale ?? DEFAULT_LOCALE
310
+ const conditions = [
311
+ eq(entries.collection, opts.collection),
312
+ eq(entryLocales.locale, locale),
313
+ ]
314
+ if (opts.status) conditions.push(eq(entryLocales.status, opts.status))
315
+ if (opts.parentId !== undefined) {
316
+ conditions.push(opts.parentId === null ? isNull(entries.parentId) : eq(entries.parentId, opts.parentId))
317
+ }
318
+ if (opts.createdBy) conditions.push(eq(entries.createdBy, opts.createdBy))
319
+ if (opts.publishedAfter) conditions.push(gte(entryLocales.publishedAt, opts.publishedAfter))
320
+ if (opts.publishedBefore) conditions.push(lte(entryLocales.publishedAt, opts.publishedBefore))
321
+
322
+ const direction = opts.order === 'asc' ? asc : desc
323
+ const order =
324
+ opts.orderBy === 'publishedAt' ? [direction(entryLocales.publishedAt)] as const
325
+ : opts.orderBy === 'createdAt' ? [direction(entries.createdAt)] as const
326
+ : opts.orderBy === 'updatedAt' ? [direction(entryLocales.updatedAt)] as const
327
+ : [asc(entries.sortOrder), desc(entryLocales.updatedAt)] as const
328
+
329
+ const base = this.db.select({ shell: entries, loc: entryLocales })
330
+ .from(entries)
331
+ .innerJoin(entryLocales, eq(entryLocales.entryId, entries.id))
332
+ .where(and(...conditions))
333
+ .orderBy(...order)
334
+ const limited = opts.limit !== undefined ? base.limit(opts.limit) : base
335
+ const paged = opts.offset !== undefined ? limited.offset(opts.offset) : limited
336
+ const rows = await paged
337
+ return rows.map((r) => joinToEntry(r.shell, r.loc))
338
+ }
339
+
340
+ async tree(collection: string, locale: string = DEFAULT_LOCALE): Promise<EntryNode[]> {
341
+ const rows = await this.db.select({ shell: entries, loc: entryLocales })
342
+ .from(entries)
343
+ .innerJoin(entryLocales, eq(entryLocales.entryId, entries.id))
344
+ .where(and(eq(entries.collection, collection), eq(entryLocales.locale, locale)))
345
+ .orderBy(asc(entries.sortOrder), desc(entryLocales.updatedAt))
346
+
347
+ const byParent = new Map<string | null, EntryNode[]>()
348
+ for (const r of rows) {
349
+ const node: EntryNode = { ...joinToEntry(r.shell, r.loc), children: [] }
350
+ const bucket = byParent.get(node.parentId) ?? []
351
+ bucket.push(node)
352
+ byParent.set(node.parentId, bucket)
353
+ }
354
+ function attach(parentId: string | null): EntryNode[] {
355
+ const children = byParent.get(parentId) ?? []
356
+ for (const child of children) child.children = attach(child.id)
357
+ return children
358
+ }
359
+ return attach(null)
360
+ }
361
+
362
+ async move(collection: string, id: string, input: { parentId: string | null; sortOrder?: number }): Promise<EntryShell> {
363
+ const shell = await this.findShellById(id)
364
+ if (!shell || shell.collection !== collection) throw new NotFoundError(`Entry ${id} not found`)
365
+ if (await this.wouldCreateCycle(id, input.parentId)) {
366
+ throw new ValidationError('An entry cannot be moved under itself or one of its descendants.')
367
+ }
368
+ const sortOrder = input.sortOrder ?? (await this.maxSortOrder(collection, input.parentId)) + 1
369
+ const now = new Date()
370
+ await this.db.update(entries).set({
371
+ parentId: input.parentId,
372
+ sortOrder,
373
+ updatedAt: now,
374
+ }).where(eq(entries.id, id))
375
+ const next = await this.findShellById(id)
376
+ if (!next) throw new NotFoundError(`Entry ${id} not found`)
377
+ return next
378
+ }
379
+
380
+ async updateWithRevision(id: string, patch: {
381
+ locale?: string
382
+ content?: unknown
383
+ status?: 'draft' | 'published'
384
+ slug?: string
385
+ updatedBy: string
386
+ changeSummary?: string
387
+ publish?: boolean
388
+ draftsEnabled?: boolean
389
+ }): Promise<EntryRow> {
390
+ const locale = patch.locale ?? DEFAULT_LOCALE
391
+ const existing = await this.findById(id, locale)
392
+ if (!existing) throw new NotFoundError(`Entry ${id} (${locale}) not found`)
393
+
394
+ let nextSlug = existing.slug
395
+ if (patch.slug !== undefined && patch.slug !== existing.slug) {
396
+ nextSlug = await this.resolveUniqueSlug(existing.collection, locale, patch.slug, id)
397
+ }
398
+ const now = new Date()
399
+ const nextVersion = existing.version + 1
400
+ const workingContent = patch.content ?? (
401
+ patch.draftsEnabled && existing.draftContent != null ? existing.draftContent : existing.content
402
+ )
403
+
404
+ const localeWhere = and(eq(entryLocales.entryId, id), eq(entryLocales.locale, locale))
405
+
406
+ if (patch.draftsEnabled) {
407
+ const publishNow = patch.publish === true
408
+ const next = publishNow
409
+ ? {
410
+ content: workingContent,
411
+ draftContent: null,
412
+ status: 'published' as const,
413
+ publishedAt: existing.publishedAt ?? now,
414
+ slug: nextSlug,
415
+ version: nextVersion,
416
+ updatedAt: now,
417
+ updatedBy: patch.updatedBy,
418
+ }
419
+ : {
420
+ content: existing.content,
421
+ draftContent: workingContent,
422
+ status: existing.status,
423
+ publishedAt: existing.publishedAt,
424
+ slug: nextSlug,
425
+ version: nextVersion,
426
+ updatedAt: now,
427
+ updatedBy: patch.updatedBy,
428
+ }
429
+
430
+ await this.db.batch([
431
+ this.db.insert(entryRevisions).values({
432
+ id: nanoid(), entryId: id, locale, version: nextVersion, content: workingContent,
433
+ authorId: patch.updatedBy, changeSummary: patch.changeSummary ?? null, createdAt: now,
434
+ }),
435
+ this.db.update(entryLocales).set(next).where(localeWhere),
436
+ this.db.update(entries).set({ updatedAt: now }).where(eq(entries.id, id)),
437
+ ])
438
+ return {
439
+ ...existing,
440
+ slug: next.slug,
441
+ status: next.status,
442
+ version: next.version,
443
+ content: next.content,
444
+ draftContent: next.draftContent ?? null,
445
+ hasUnpublishedChanges: next.draftContent != null,
446
+ publishedAt: next.publishedAt,
447
+ updatedAt: next.updatedAt,
448
+ updatedBy: next.updatedBy,
449
+ }
450
+ }
451
+
452
+ const nextContent = patch.content ?? existing.content
453
+ const nextStatus = patch.status ?? existing.status
454
+ const nextPublishedAt = nextStatus === 'published' && !existing.publishedAt ? now : existing.publishedAt
455
+ await this.db.batch([
456
+ this.db.insert(entryRevisions).values({
457
+ id: nanoid(), entryId: id, locale, version: nextVersion, content: nextContent,
458
+ authorId: patch.updatedBy, changeSummary: patch.changeSummary ?? null, createdAt: now,
459
+ }),
460
+ this.db.update(entryLocales).set({
461
+ content: nextContent,
462
+ slug: nextSlug,
463
+ status: nextStatus,
464
+ version: nextVersion,
465
+ publishedAt: nextPublishedAt,
466
+ updatedAt: now,
467
+ updatedBy: patch.updatedBy,
468
+ }).where(localeWhere),
469
+ this.db.update(entries).set({ updatedAt: now }).where(eq(entries.id, id)),
470
+ ])
471
+ return {
472
+ ...existing,
473
+ content: nextContent,
474
+ slug: nextSlug,
475
+ status: nextStatus,
476
+ version: nextVersion,
477
+ publishedAt: nextPublishedAt,
478
+ updatedAt: now,
479
+ updatedBy: patch.updatedBy,
480
+ hasUnpublishedChanges: false,
481
+ }
482
+ }
483
+
484
+ async publish(id: string, updatedBy: string, locale: string = DEFAULT_LOCALE): Promise<EntryRow> {
485
+ return this.updateWithRevision(id, { publish: true, draftsEnabled: true, updatedBy, locale })
486
+ }
487
+
488
+ async delete(id: string): Promise<void> {
489
+ await this.db.delete(entries).where(eq(entries.id, id))
490
+ }
491
+
492
+ /** Delete only one locale of an entry; the entry shell remains if other locales exist. */
493
+ async deleteLocale(id: string, locale: string): Promise<void> {
494
+ await this.db.delete(entryLocales).where(and(
495
+ eq(entryLocales.entryId, id),
496
+ eq(entryLocales.locale, locale),
497
+ ))
498
+ // Also remove revisions for that locale; entries cascade is broader than we want here.
499
+ await this.db.delete(entryRevisions).where(and(
500
+ eq(entryRevisions.entryId, id),
501
+ eq(entryRevisions.locale, locale),
502
+ ))
503
+ }
504
+ }