@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,270 @@
1
+ import { eq, and, gt, lt, sql, desc } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import type { VulseDb } from '../db.js'
4
+ import {
5
+ vulseForms,
6
+ vulseFormSubmissions,
7
+ vulseFormUniqueValues,
8
+ vulseFormUploadDrafts,
9
+ } from '../schema.js'
10
+ import { NotFoundError, ValidationError } from '../errors.js'
11
+ import {
12
+ type FormDefinition,
13
+ FormDefinitionSchema,
14
+ } from '../forms/definition.js'
15
+
16
+ export interface FormRow {
17
+ handle: string
18
+ label: string
19
+ definition: FormDefinition
20
+ enabled: boolean
21
+ createdAt: Date
22
+ updatedAt: Date
23
+ submissionCount?: number
24
+ }
25
+
26
+ function parseRow(row: typeof vulseForms.$inferSelect): FormRow {
27
+ return {
28
+ handle: row.handle,
29
+ label: row.label,
30
+ definition: FormDefinitionSchema.parse(row.definition),
31
+ enabled: row.enabled,
32
+ createdAt: row.createdAt,
33
+ updatedAt: row.updatedAt,
34
+ }
35
+ }
36
+
37
+ export class FormsRepo {
38
+ constructor(private db: VulseDb) {}
39
+
40
+ async create(input: FormDefinition): Promise<FormRow> {
41
+ const parsed = FormDefinitionSchema.safeParse(input)
42
+ if (!parsed.success) throw new ValidationError('Invalid form', { issues: parsed.error.issues })
43
+ const def = parsed.data
44
+ const now = new Date()
45
+ await this.db.insert(vulseForms).values({
46
+ handle: def.handle,
47
+ label: def.label,
48
+ definition: def,
49
+ enabled: def.settings.enabled,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ })
53
+ const row = await this.findByHandle(def.handle)
54
+ if (!row) throw new Error(`form not found after create: ${def.handle}`)
55
+ return row
56
+ }
57
+
58
+ async findByHandle(handle: string): Promise<FormRow | null> {
59
+ const row = await this.db.select().from(vulseForms).where(eq(vulseForms.handle, handle)).get()
60
+ return row ? parseRow(row) : null
61
+ }
62
+
63
+ async list(): Promise<FormRow[]> {
64
+ const rows = await this.db
65
+ .select({
66
+ handle: vulseForms.handle,
67
+ label: vulseForms.label,
68
+ definition: vulseForms.definition,
69
+ enabled: vulseForms.enabled,
70
+ createdAt: vulseForms.createdAt,
71
+ updatedAt: vulseForms.updatedAt,
72
+ submissionCount: sql<number>`(
73
+ SELECT COUNT(*) FROM vulse_form_submissions
74
+ WHERE form_handle = ${vulseForms.handle}
75
+ )`.mapWith(Number),
76
+ })
77
+ .from(vulseForms)
78
+ .orderBy(vulseForms.createdAt)
79
+ return rows.map((r) => ({
80
+ ...parseRow({
81
+ ...r,
82
+ // The list query intentionally omits schemaVersion; supply the default
83
+ // so parseRow's type-cast remains accurate without an over-broad `as`.
84
+ schemaVersion: 1,
85
+ } as typeof vulseForms.$inferSelect),
86
+ submissionCount: r.submissionCount,
87
+ }))
88
+ }
89
+
90
+ async update(handle: string, input: FormDefinition): Promise<FormRow> {
91
+ if (input.handle !== handle) {
92
+ throw new Error(`form handle is immutable (got '${input.handle}', expected '${handle}')`)
93
+ }
94
+ const parsed = FormDefinitionSchema.safeParse(input)
95
+ if (!parsed.success) throw new ValidationError('Invalid form', { issues: parsed.error.issues })
96
+ const def = parsed.data
97
+ await this.db.update(vulseForms).set({
98
+ label: def.label,
99
+ definition: def,
100
+ enabled: def.settings.enabled,
101
+ updatedAt: new Date(),
102
+ }).where(eq(vulseForms.handle, handle))
103
+ const row = await this.findByHandle(handle)
104
+ if (!row) throw new NotFoundError('form not found')
105
+ return row
106
+ }
107
+
108
+ async delete(handle: string): Promise<void> {
109
+ await this.db.delete(vulseForms).where(eq(vulseForms.handle, handle))
110
+ }
111
+ }
112
+
113
+ export interface SubmissionMeta {
114
+ ip?: string
115
+ userAgent?: string
116
+ referer?: string
117
+ locale?: string
118
+ }
119
+
120
+ export interface FileRef {
121
+ field: string
122
+ mediaId: string
123
+ }
124
+
125
+ export interface SubmissionRow {
126
+ id: string
127
+ formHandle: string
128
+ payload: Record<string, unknown>
129
+ fileRefs: FileRef[]
130
+ meta: SubmissionMeta
131
+ status: 'received' | 'processed' | 'failed'
132
+ error: string | null
133
+ createdAt: Date
134
+ }
135
+
136
+ function parseSubmission(row: typeof vulseFormSubmissions.$inferSelect): SubmissionRow {
137
+ return {
138
+ id: row.id,
139
+ formHandle: row.formHandle,
140
+ payload: row.payload as Record<string, unknown>,
141
+ fileRefs: (row.fileRefs ?? []) as FileRef[],
142
+ meta: row.meta as SubmissionMeta,
143
+ status: row.status,
144
+ error: row.error ?? null,
145
+ createdAt: row.createdAt,
146
+ }
147
+ }
148
+
149
+ export class SubmissionsRepo {
150
+ constructor(private db: VulseDb) {}
151
+
152
+ async create(input: {
153
+ formHandle: string
154
+ payload: Record<string, unknown>
155
+ fileRefs?: FileRef[]
156
+ meta: SubmissionMeta
157
+ }): Promise<SubmissionRow> {
158
+ const now = new Date()
159
+ const row = {
160
+ id: nanoid(),
161
+ formHandle: input.formHandle,
162
+ payload: input.payload,
163
+ fileRefs: input.fileRefs ?? [],
164
+ meta: input.meta,
165
+ status: 'received' as const,
166
+ error: null,
167
+ createdAt: now,
168
+ }
169
+ await this.db.insert(vulseFormSubmissions).values(row)
170
+ return parseSubmission(row as typeof vulseFormSubmissions.$inferSelect)
171
+ }
172
+
173
+ async findById(id: string): Promise<SubmissionRow | null> {
174
+ const row = await this.db.select().from(vulseFormSubmissions).where(eq(vulseFormSubmissions.id, id)).get()
175
+ return row ? parseSubmission(row) : null
176
+ }
177
+
178
+ async list(opts: { formHandle: string; limit?: number; offset?: number }): Promise<SubmissionRow[]> {
179
+ let query = this.db.select().from(vulseFormSubmissions)
180
+ .where(eq(vulseFormSubmissions.formHandle, opts.formHandle))
181
+ .orderBy(desc(vulseFormSubmissions.createdAt))
182
+ if (opts.limit !== undefined) query = query.limit(opts.limit) as typeof query
183
+ if (opts.offset !== undefined) query = query.offset(opts.offset) as typeof query
184
+ const rows = await query
185
+ return rows.map(parseSubmission)
186
+ }
187
+
188
+ async delete(id: string): Promise<void> {
189
+ await this.db.delete(vulseFormUniqueValues).where(eq(vulseFormUniqueValues.submissionId, id))
190
+ await this.db.delete(vulseFormSubmissions).where(eq(vulseFormSubmissions.id, id))
191
+ }
192
+
193
+ async deleteMany(ids: string[]): Promise<number> {
194
+ if (ids.length === 0) return 0
195
+ for (const id of ids) {
196
+ await this.db.delete(vulseFormUniqueValues).where(eq(vulseFormUniqueValues.submissionId, id))
197
+ }
198
+ let deleted = 0
199
+ for (const id of ids) {
200
+ await this.db.delete(vulseFormSubmissions).where(eq(vulseFormSubmissions.id, id))
201
+ deleted++
202
+ }
203
+ return deleted
204
+ }
205
+
206
+ async updateStatus(id: string, status: 'processed' | 'failed', error?: string | null): Promise<void> {
207
+ await this.db.update(vulseFormSubmissions).set({
208
+ status,
209
+ error: error ?? null,
210
+ }).where(eq(vulseFormSubmissions.id, id))
211
+ }
212
+ }
213
+
214
+ export interface UploadDraftRow {
215
+ id: string
216
+ formHandle: string
217
+ fieldName: string
218
+ mediaId: string
219
+ expiresAt: Date
220
+ createdAt: Date
221
+ }
222
+
223
+ export class FormUploadDraftsRepo {
224
+ constructor(private db: VulseDb) {}
225
+
226
+ async create(input: {
227
+ formHandle: string
228
+ fieldName: string
229
+ mediaId: string
230
+ expiresAt: Date
231
+ }): Promise<UploadDraftRow> {
232
+ const now = new Date()
233
+ const row = {
234
+ id: nanoid(),
235
+ formHandle: input.formHandle,
236
+ fieldName: input.fieldName,
237
+ mediaId: input.mediaId,
238
+ expiresAt: input.expiresAt,
239
+ createdAt: now,
240
+ }
241
+ await this.db.insert(vulseFormUploadDrafts).values(row)
242
+ return row
243
+ }
244
+
245
+ async findValid(formHandle: string, fieldName: string, mediaId: string): Promise<UploadDraftRow | null> {
246
+ const row = await this.db.select().from(vulseFormUploadDrafts)
247
+ .where(and(
248
+ eq(vulseFormUploadDrafts.formHandle, formHandle),
249
+ eq(vulseFormUploadDrafts.fieldName, fieldName),
250
+ eq(vulseFormUploadDrafts.mediaId, mediaId),
251
+ gt(vulseFormUploadDrafts.expiresAt, new Date()),
252
+ ))
253
+ .get()
254
+ return row ?? null
255
+ }
256
+
257
+ async delete(id: string): Promise<void> {
258
+ await this.db.delete(vulseFormUploadDrafts).where(eq(vulseFormUploadDrafts.id, id))
259
+ }
260
+
261
+ async listExpired(before: Date): Promise<UploadDraftRow[]> {
262
+ const rows = await this.db.select().from(vulseFormUploadDrafts)
263
+ .where(lt(vulseFormUploadDrafts.expiresAt, before))
264
+ return rows
265
+ }
266
+
267
+ async attachToSubmission(draftId: string): Promise<void> {
268
+ await this.delete(draftId)
269
+ }
270
+ }
@@ -0,0 +1,179 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { VulseDb } from '../db.js'
3
+ import { vulseGlobalSets, vulseGlobalValues } from '../schema.js'
4
+ import { ConflictError, NotFoundError, ValidationError } from '../errors.js'
5
+ import { loadCompiledSets } from '../sets/service.js'
6
+ import {
7
+ type GlobalSetDefinition,
8
+ GlobalSetDefinitionSchema,
9
+ hashGlobalSetDefinition,
10
+ } from '../globals/definition.js'
11
+ import { compileGlobalSet } from '../globals/compile.js'
12
+
13
+ export interface GlobalSetRow {
14
+ handle: string
15
+ label: string
16
+ definition: GlobalSetDefinition
17
+ blueprintHash: string
18
+ createdAt: Date
19
+ updatedAt: Date
20
+ }
21
+
22
+ export interface GlobalValueRow {
23
+ handle: string
24
+ content: Record<string, unknown>
25
+ createdAt: Date
26
+ updatedAt: Date
27
+ }
28
+
29
+ export type PublicGlobals = Record<string, Record<string, unknown>>
30
+
31
+ function parseSetRow(row: typeof vulseGlobalSets.$inferSelect): GlobalSetRow {
32
+ return {
33
+ handle: row.handle,
34
+ label: row.label,
35
+ definition: GlobalSetDefinitionSchema.parse(row.definition),
36
+ blueprintHash: row.blueprintHash,
37
+ createdAt: row.createdAt,
38
+ updatedAt: row.updatedAt,
39
+ }
40
+ }
41
+
42
+ function parseValueRow(row: typeof vulseGlobalValues.$inferSelect): GlobalValueRow {
43
+ return {
44
+ handle: row.handle,
45
+ content: (row.content ?? {}) as Record<string, unknown>,
46
+ createdAt: row.createdAt,
47
+ updatedAt: row.updatedAt,
48
+ }
49
+ }
50
+
51
+ export class GlobalsRepo {
52
+ constructor(private db: VulseDb) {}
53
+
54
+ async listSets(): Promise<GlobalSetRow[]> {
55
+ const rows = await this.db.select().from(vulseGlobalSets).orderBy(vulseGlobalSets.createdAt)
56
+ return rows.map(parseSetRow)
57
+ }
58
+
59
+ async findSetByHandle(handle: string): Promise<GlobalSetRow | null> {
60
+ const row = await this.db.select().from(vulseGlobalSets).where(eq(vulseGlobalSets.handle, handle)).get()
61
+ return row ? parseSetRow(row) : null
62
+ }
63
+
64
+ async createSet(input: GlobalSetDefinition): Promise<GlobalSetRow> {
65
+ const parsed = GlobalSetDefinitionSchema.safeParse(input)
66
+ if (!parsed.success) throw new ValidationError('Invalid global set', { issues: parsed.error.issues })
67
+ const def = parsed.data
68
+ const existing = await this.findSetByHandle(def.handle)
69
+ if (existing) throw new ConflictError(`global set already exists: ${def.handle}`)
70
+
71
+ const now = new Date()
72
+ const hash = await hashGlobalSetDefinition(def)
73
+ await this.db.insert(vulseGlobalSets).values({
74
+ handle: def.handle,
75
+ label: def.label,
76
+ definition: def,
77
+ blueprintHash: hash,
78
+ createdAt: now,
79
+ updatedAt: now,
80
+ })
81
+ await this.db.insert(vulseGlobalValues).values({
82
+ handle: def.handle,
83
+ content: {},
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ })
87
+
88
+ const row = await this.findSetByHandle(def.handle)
89
+ if (!row) throw new Error(`global set not found after create: ${def.handle}`)
90
+ return row
91
+ }
92
+
93
+ async updateSet(handle: string, input: GlobalSetDefinition): Promise<GlobalSetRow> {
94
+ if (input.handle !== handle) {
95
+ throw new ValidationError('Global set handles are immutable', { issues: [{ path: ['handle'], message: 'Handle cannot be changed' }] })
96
+ }
97
+ const parsed = GlobalSetDefinitionSchema.safeParse(input)
98
+ if (!parsed.success) throw new ValidationError('Invalid global set', { issues: parsed.error.issues })
99
+ const def = parsed.data
100
+ const existing = await this.findSetByHandle(handle)
101
+ if (!existing) throw new NotFoundError('global set not found')
102
+
103
+ await this.db.update(vulseGlobalSets).set({
104
+ label: def.label,
105
+ definition: def,
106
+ blueprintHash: await hashGlobalSetDefinition(def),
107
+ updatedAt: new Date(),
108
+ }).where(eq(vulseGlobalSets.handle, handle))
109
+
110
+ const row = await this.findSetByHandle(handle)
111
+ if (!row) throw new NotFoundError('global set not found')
112
+ return row
113
+ }
114
+
115
+ async deleteSet(handle: string): Promise<void> {
116
+ const existing = await this.findSetByHandle(handle)
117
+ if (!existing) throw new NotFoundError('global set not found')
118
+ await this.db.delete(vulseGlobalSets).where(eq(vulseGlobalSets.handle, handle))
119
+ }
120
+
121
+ async getValue(handle: string): Promise<GlobalValueRow | null> {
122
+ const set = await this.findSetByHandle(handle)
123
+ if (!set) return null
124
+ const row = await this.db.select().from(vulseGlobalValues).where(eq(vulseGlobalValues.handle, handle)).get()
125
+ return row ? parseValueRow(row) : null
126
+ }
127
+
128
+ async updateValue(handle: string, input: unknown): Promise<GlobalValueRow> {
129
+ const set = await this.findSetByHandle(handle)
130
+ if (!set) throw new NotFoundError('global set not found')
131
+
132
+ const sets = await loadCompiledSets(this.db)
133
+ const compiled = await compileGlobalSet(set.definition, sets)
134
+ const result = compiled.schema.safeParse(input)
135
+ if (!result.success) {
136
+ throw new ValidationError('Invalid global value', { issues: result.error.issues })
137
+ }
138
+
139
+ const now = new Date()
140
+ const content = result.data as Record<string, unknown>
141
+ await this.db.insert(vulseGlobalValues).values({
142
+ handle,
143
+ content,
144
+ createdAt: now,
145
+ updatedAt: now,
146
+ }).onConflictDoUpdate({
147
+ target: vulseGlobalValues.handle,
148
+ set: { content, updatedAt: now },
149
+ })
150
+
151
+ const row = await this.getValue(handle)
152
+ if (!row) throw new Error(`global value not found after update: ${handle}`)
153
+ return row
154
+ }
155
+
156
+ async publicValues(): Promise<PublicGlobals> {
157
+ const rows = await this.db
158
+ .select({
159
+ handle: vulseGlobalValues.handle,
160
+ content: vulseGlobalValues.content,
161
+ })
162
+ .from(vulseGlobalValues)
163
+ .innerJoin(vulseGlobalSets, eq(vulseGlobalValues.handle, vulseGlobalSets.handle))
164
+ .orderBy(vulseGlobalSets.createdAt)
165
+
166
+ const out: PublicGlobals = {}
167
+ for (const row of rows) {
168
+ out[row.handle] = (row.content ?? {}) as Record<string, unknown>
169
+ }
170
+ return out
171
+ }
172
+
173
+ async publicValue(handle: string): Promise<Record<string, unknown> | null> {
174
+ const set = await this.findSetByHandle(handle)
175
+ if (!set) return null
176
+ const value = await this.getValue(handle)
177
+ return value?.content ?? {}
178
+ }
179
+ }
@@ -0,0 +1,106 @@
1
+ import { and, desc, eq, isNotNull, isNull, lt } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import type { VulseDb } from '../db.js'
4
+ import { media } from '../schema.js'
5
+ import { NotFoundError } from '../errors.js'
6
+
7
+ export interface MediaRow {
8
+ id: string
9
+ r2Key: string
10
+ mime: string
11
+ size: number
12
+ width: number | null
13
+ height: number | null
14
+ alt: string | null
15
+ blurhash: string | null
16
+ uploadedBy: string | null
17
+ createdAt: Date
18
+ deletedAt: Date | null
19
+ }
20
+
21
+ function rowToMedia(row: typeof media.$inferSelect): MediaRow {
22
+ return {
23
+ id: row.id,
24
+ r2Key: row.r2Key,
25
+ mime: row.mime,
26
+ size: row.size,
27
+ width: row.width ?? null,
28
+ height: row.height ?? null,
29
+ alt: row.alt ?? null,
30
+ blurhash: row.blurhash ?? null,
31
+ uploadedBy: row.uploadedBy ?? null,
32
+ createdAt: row.createdAt,
33
+ deletedAt: row.deletedAt ?? null,
34
+ }
35
+ }
36
+
37
+ export class MediaRepo {
38
+ constructor(private db: VulseDb) {}
39
+
40
+ async create(input: {
41
+ r2Key: string
42
+ mime: string
43
+ size: number
44
+ uploadedBy?: string | null
45
+ width?: number | null
46
+ height?: number | null
47
+ alt?: string | null
48
+ blurhash?: string | null
49
+ }): Promise<MediaRow> {
50
+ const now = new Date()
51
+ const row = {
52
+ id: nanoid(),
53
+ r2Key: input.r2Key,
54
+ mime: input.mime,
55
+ size: input.size,
56
+ width: input.width ?? null,
57
+ height: input.height ?? null,
58
+ alt: input.alt ?? null,
59
+ blurhash: input.blurhash ?? null,
60
+ uploadedBy: input.uploadedBy ?? null,
61
+ createdAt: now,
62
+ deletedAt: null,
63
+ }
64
+ await this.db.insert(media).values(row)
65
+ return rowToMedia(row as typeof media.$inferSelect)
66
+ }
67
+
68
+ async list(opts: { includeDeleted?: boolean; limit?: number; offset?: number } = {}): Promise<MediaRow[]> {
69
+ const conditions = opts.includeDeleted ? undefined : isNull(media.deletedAt)
70
+ let query = this.db.select().from(media)
71
+ if (conditions) query = query.where(conditions) as typeof query
72
+ query = query.orderBy(desc(media.createdAt)) as typeof query
73
+ if (opts.limit !== undefined) query = query.limit(opts.limit) as typeof query
74
+ if (opts.offset !== undefined) query = query.offset(opts.offset) as typeof query
75
+ const rows = await query
76
+ return rows.map(rowToMedia)
77
+ }
78
+
79
+ async findById(id: string): Promise<MediaRow | null> {
80
+ const [row] = await this.db.select().from(media).where(eq(media.id, id))
81
+ return row ? rowToMedia(row) : null
82
+ }
83
+
84
+ async softDelete(id: string): Promise<void> {
85
+ const existing = await this.findById(id)
86
+ if (!existing) throw new NotFoundError(`Media ${id} not found`)
87
+ await this.db.update(media).set({ deletedAt: new Date() }).where(eq(media.id, id))
88
+ }
89
+
90
+ async updateAlt(id: string, alt: string): Promise<void> {
91
+ const existing = await this.findById(id)
92
+ if (!existing) throw new NotFoundError(`Media ${id} not found`)
93
+ await this.db.update(media).set({ alt }).where(eq(media.id, id))
94
+ }
95
+
96
+ async listPurgeable(days: number): Promise<MediaRow[]> {
97
+ const cutoff = new Date(Date.now() - days * 86_400_000)
98
+ const rows = await this.db.select().from(media)
99
+ .where(and(isNotNull(media.deletedAt), lt(media.deletedAt, cutoff)))
100
+ return rows.map(rowToMedia)
101
+ }
102
+
103
+ async hardDelete(id: string): Promise<void> {
104
+ await this.db.delete(media).where(eq(media.id, id))
105
+ }
106
+ }
@@ -0,0 +1,108 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { eq, lt } from 'drizzle-orm'
3
+ import type { VulseDb } from '../db.js'
4
+ import { vulsePreviewSessions } from '../schema.js'
5
+ import { DEFAULT_LOCALE } from './entries.js'
6
+
7
+ const DEFAULT_TTL_MS = 60 * 60 * 1000
8
+
9
+ export interface PreviewSessionRow {
10
+ id: string
11
+ userId: string
12
+ entryId: string | null
13
+ collection: string
14
+ locale: string
15
+ slug: string
16
+ content: unknown
17
+ expiresAt: Date
18
+ createdAt: Date
19
+ updatedAt: Date
20
+ }
21
+
22
+ function mapRow(row: typeof vulsePreviewSessions.$inferSelect): PreviewSessionRow {
23
+ return {
24
+ id: row.id,
25
+ userId: row.userId,
26
+ entryId: row.entryId ?? null,
27
+ collection: row.collection,
28
+ locale: row.locale,
29
+ slug: row.slug,
30
+ content: row.content,
31
+ expiresAt: row.expiresAt,
32
+ createdAt: row.createdAt,
33
+ updatedAt: row.updatedAt,
34
+ }
35
+ }
36
+
37
+ export class PreviewSessionsRepo {
38
+ constructor(private db: VulseDb) {}
39
+
40
+ async create(input: {
41
+ userId: string
42
+ collection: string
43
+ slug: string
44
+ content: unknown
45
+ locale?: string
46
+ entryId?: string | null
47
+ ttlMs?: number
48
+ }): Promise<PreviewSessionRow> {
49
+ const now = new Date()
50
+ const ttl = input.ttlMs ?? DEFAULT_TTL_MS
51
+ const id = nanoid(32)
52
+ const row = {
53
+ id,
54
+ userId: input.userId,
55
+ entryId: input.entryId ?? null,
56
+ collection: input.collection,
57
+ locale: input.locale ?? DEFAULT_LOCALE,
58
+ slug: input.slug,
59
+ content: input.content,
60
+ expiresAt: new Date(now.getTime() + ttl),
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ }
64
+ await this.db.insert(vulsePreviewSessions).values(row)
65
+ return mapRow(row)
66
+ }
67
+
68
+ async findById(id: string): Promise<PreviewSessionRow | null> {
69
+ const row = await this.db.query.vulsePreviewSessions.findFirst({
70
+ where: eq(vulsePreviewSessions.id, id),
71
+ })
72
+ if (!row) return null
73
+ if (row.expiresAt.getTime() < Date.now()) return null
74
+ return mapRow(row)
75
+ }
76
+
77
+ async update(id: string, userId: string, patch: { slug?: string; content?: unknown; locale?: string }): Promise<PreviewSessionRow | null> {
78
+ const existing = await this.findById(id)
79
+ if (!existing || existing.userId !== userId) return null
80
+ const now = new Date()
81
+ const next = {
82
+ slug: patch.slug ?? existing.slug,
83
+ content: patch.content ?? existing.content,
84
+ locale: patch.locale ?? existing.locale,
85
+ expiresAt: new Date(now.getTime() + DEFAULT_TTL_MS),
86
+ updatedAt: now,
87
+ }
88
+ await this.db.update(vulsePreviewSessions).set(next).where(eq(vulsePreviewSessions.id, id))
89
+ return { ...existing, ...next }
90
+ }
91
+
92
+ async delete(id: string, userId: string): Promise<boolean> {
93
+ const existing = await this.findById(id)
94
+ if (!existing || existing.userId !== userId) return false
95
+ await this.db.delete(vulsePreviewSessions).where(eq(vulsePreviewSessions.id, id))
96
+ return true
97
+ }
98
+
99
+ async purgeExpired(now = new Date()): Promise<number> {
100
+ const expired = await this.db.select({ id: vulsePreviewSessions.id })
101
+ .from(vulsePreviewSessions)
102
+ .where(lt(vulsePreviewSessions.expiresAt, now))
103
+ for (const row of expired) {
104
+ await this.db.delete(vulsePreviewSessions).where(eq(vulsePreviewSessions.id, row.id))
105
+ }
106
+ return expired.length
107
+ }
108
+ }