@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,60 @@
1
+ import { and, desc, eq } from 'drizzle-orm'
2
+ import type { VulseDb } from '../db.js'
3
+ import { entryRevisions } from '../schema.js'
4
+ import { NotFoundError } from '../errors.js'
5
+ import { DEFAULT_LOCALE, EntriesRepo } from './entries.js'
6
+
7
+ export interface RevisionRow {
8
+ id: string
9
+ entryId: string
10
+ locale: string
11
+ version: number
12
+ content: unknown
13
+ authorId: string | null
14
+ changeSummary: string | null
15
+ createdAt: Date
16
+ }
17
+
18
+ export class RevisionsRepo {
19
+ constructor(private db: VulseDb) {}
20
+
21
+ async listByEntry(entryId: string, locale: string = DEFAULT_LOCALE): Promise<RevisionRow[]> {
22
+ return await this.db.select().from(entryRevisions)
23
+ .where(and(eq(entryRevisions.entryId, entryId), eq(entryRevisions.locale, locale)))
24
+ .orderBy(desc(entryRevisions.version)) as RevisionRow[]
25
+ }
26
+
27
+ async getVersion(entryId: string, version: number, locale: string = DEFAULT_LOCALE): Promise<RevisionRow | null> {
28
+ const [row] = await this.db.select().from(entryRevisions)
29
+ .where(and(
30
+ eq(entryRevisions.entryId, entryId),
31
+ eq(entryRevisions.locale, locale),
32
+ eq(entryRevisions.version, version),
33
+ ))
34
+ return (row as RevisionRow | undefined) ?? null
35
+ }
36
+
37
+ async restore(entryId: string, version: number, opts: { userId: string; locale?: string }): Promise<void> {
38
+ const locale = opts.locale ?? DEFAULT_LOCALE
39
+ const target = await this.getVersion(entryId, version, locale)
40
+ if (!target) throw new NotFoundError(`Revision ${version} for ${entryId} (${locale}) not found`)
41
+ const repo = new EntriesRepo(this.db)
42
+ await repo.updateWithRevision(entryId, {
43
+ locale,
44
+ content: target.content,
45
+ updatedBy: opts.userId,
46
+ changeSummary: `Restored v${version}`,
47
+ })
48
+ }
49
+
50
+ /** Keep only the most recent `keep` revisions per (entry, locale). */
51
+ async prune(entryId: string, locale: string, keep: number): Promise<number> {
52
+ if (keep < 1) return 0
53
+ const all = await this.listByEntry(entryId, locale)
54
+ const toDelete = all.slice(keep)
55
+ for (const r of toDelete) {
56
+ await this.db.delete(entryRevisions).where(eq(entryRevisions.id, r.id))
57
+ }
58
+ return toDelete.length
59
+ }
60
+ }
@@ -0,0 +1,23 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { VulseDb } from '../db.js'
3
+ import { settings } from '../schema.js'
4
+
5
+ export class SettingsRepo {
6
+ constructor(private db: VulseDb) {}
7
+
8
+ async get<T = unknown>(key: string): Promise<T | null> {
9
+ const [row] = await this.db.select().from(settings).where(eq(settings.key, key))
10
+ return (row?.value as T | undefined) ?? null
11
+ }
12
+
13
+ async set(key: string, value: unknown): Promise<void> {
14
+ const now = new Date()
15
+ await this.db.insert(settings).values({ key, value, updatedAt: now })
16
+ .onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: now } })
17
+ }
18
+
19
+ async all(): Promise<Record<string, unknown>> {
20
+ const rows = await this.db.select().from(settings)
21
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]))
22
+ }
23
+ }
@@ -0,0 +1,244 @@
1
+ import { sqliteTable, text, integer, index, uniqueIndex, primaryKey, type AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
2
+ import { sql } from 'drizzle-orm'
3
+
4
+ // --- Vulse content (i18n model) ---
5
+ //
6
+ // `vulse_entries` is the single-identity shell: one row per logical entry, holding
7
+ // only locale-independent data (tree position, ownership). Per-locale data
8
+ // (slug, status, content, drafts) lives in `vulse_entry_locales`, keyed by
9
+ // (entry_id, locale). Slug uniqueness is per (collection, locale).
10
+
11
+ export const entries = sqliteTable('vulse_entries', {
12
+ id: text('id').primaryKey(),
13
+ collection: text('collection').notNull(),
14
+ parentId: text('parent_id').references((): AnySQLiteColumn => entries.id, { onDelete: 'cascade' }),
15
+ sortOrder: integer('sort_order').notNull().default(0),
16
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
17
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
18
+ createdBy: text('created_by'),
19
+ }, (t) => ({
20
+ byCollection: index('vulse_entries_collection').on(t.collection),
21
+ byTree: index('vulse_entries_tree').on(t.collection, t.parentId, t.sortOrder),
22
+ }))
23
+
24
+ export const entryLocales = sqliteTable('vulse_entry_locales', {
25
+ entryId: text('entry_id').notNull().references(() => entries.id, { onDelete: 'cascade' }),
26
+ collection: text('collection').notNull(),
27
+ locale: text('locale').notNull(),
28
+ slug: text('slug').notNull(),
29
+ status: text('status', { enum: ['draft', 'published'] }).notNull().default('draft'),
30
+ version: integer('version').notNull().default(1),
31
+ content: text('content', { mode: 'json' }).notNull(),
32
+ draftContent: text('draft_content', { mode: 'json' }),
33
+ publishedAt: integer('published_at', { mode: 'timestamp_ms' }),
34
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
35
+ updatedBy: text('updated_by'),
36
+ }, (t) => ({
37
+ pk: primaryKey({ columns: [t.entryId, t.locale] }),
38
+ uniqSlug: uniqueIndex('vulse_entry_locales_collection_locale_slug').on(t.collection, t.locale, t.slug),
39
+ byStatus: index('vulse_entry_locales_status_published').on(t.collection, t.locale, t.status, t.publishedAt),
40
+ }))
41
+
42
+ export const entryRevisions = sqliteTable('vulse_entry_revisions', {
43
+ id: text('id').primaryKey(),
44
+ entryId: text('entry_id').notNull().references(() => entries.id, { onDelete: 'cascade' }),
45
+ locale: text('locale').notNull(),
46
+ version: integer('version').notNull(),
47
+ content: text('content', { mode: 'json' }).notNull(),
48
+ authorId: text('author_id'),
49
+ changeSummary: text('change_summary'),
50
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
51
+ }, (t) => ({
52
+ byEntry: index('vulse_entry_revisions_entry_locale_version').on(t.entryId, t.locale, t.version),
53
+ }))
54
+
55
+ export const media = sqliteTable('vulse_media', {
56
+ id: text('id').primaryKey(),
57
+ r2Key: text('r2_key').notNull(),
58
+ mime: text('mime').notNull(),
59
+ size: integer('size').notNull(),
60
+ width: integer('width'),
61
+ height: integer('height'),
62
+ alt: text('alt'),
63
+ blurhash: text('blurhash'),
64
+ uploadedBy: text('uploaded_by'),
65
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
66
+ deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }),
67
+ }, (t) => ({
68
+ // Partial index on active rows: every list query reads `WHERE deleted_at IS NULL`.
69
+ active: index('vulse_media_active').on(t.createdAt).where(sql`${t.deletedAt} IS NULL`),
70
+ }))
71
+
72
+ export const vulseCollections = sqliteTable('vulse_collections', {
73
+ handle: text('handle').primaryKey(),
74
+ label: text('label').notNull(),
75
+ definition: text('definition', { mode: 'json' }).notNull(),
76
+ blueprintHash: text('blueprint_hash').notNull(),
77
+ // `schema_version` lets future definition-shape changes migrate row-by-row
78
+ // without a destructive table rewrite.
79
+ schemaVersion: integer('schema_version').notNull().default(1),
80
+ singleton: integer('singleton', { mode: 'boolean' }).notNull().default(false),
81
+ tree: integer('tree', { mode: 'boolean' }).notNull().default(false),
82
+ drafts: integer('drafts', { mode: 'boolean' }).notNull().default(false),
83
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
84
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
85
+ })
86
+
87
+ export const vulseSets = sqliteTable('vulse_sets', {
88
+ handle: text('handle').primaryKey(),
89
+ label: text('label').notNull(),
90
+ definition: text('definition', { mode: 'json' }).notNull(),
91
+ schemaVersion: integer('schema_version').notNull().default(1),
92
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
93
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
94
+ })
95
+
96
+ export const settings = sqliteTable('vulse_settings', {
97
+ key: text('key').primaryKey(),
98
+ value: text('value', { mode: 'json' }).notNull(),
99
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
100
+ })
101
+
102
+ // --- Forms ---
103
+
104
+ export const vulseForms = sqliteTable('vulse_forms', {
105
+ handle: text('handle').primaryKey(),
106
+ label: text('label').notNull(),
107
+ definition: text('definition', { mode: 'json' }).notNull(),
108
+ schemaVersion: integer('schema_version').notNull().default(1),
109
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
110
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
111
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
112
+ })
113
+
114
+ export const vulseFormSubmissions = sqliteTable('vulse_form_submissions', {
115
+ id: text('id').primaryKey(),
116
+ formHandle: text('form_handle').notNull().references(() => vulseForms.handle, { onDelete: 'cascade' }),
117
+ payload: text('payload', { mode: 'json' }).notNull(),
118
+ fileRefs: text('file_refs', { mode: 'json' }).notNull().default([]),
119
+ meta: text('meta', { mode: 'json' }).notNull(),
120
+ status: text('status', { enum: ['received', 'processed', 'failed'] }).notNull().default('received'),
121
+ error: text('error'),
122
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
123
+ }, (t) => ({
124
+ byFormCreated: index('vulse_form_submissions_form_created').on(t.formHandle, t.createdAt),
125
+ byFormStatus: index('vulse_form_submissions_form_status').on(t.formHandle, t.status),
126
+ }))
127
+
128
+ export const vulseFormUploadDrafts = sqliteTable('vulse_form_upload_drafts', {
129
+ id: text('id').primaryKey(),
130
+ formHandle: text('form_handle').notNull().references(() => vulseForms.handle, { onDelete: 'cascade' }),
131
+ fieldName: text('field_name').notNull(),
132
+ mediaId: text('media_id').notNull().references(() => media.id, { onDelete: 'cascade' }),
133
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
134
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
135
+ }, (t) => ({
136
+ byExpires: index('vulse_form_upload_drafts_expires').on(t.expiresAt),
137
+ }))
138
+
139
+ export const vulseFormUniqueValues = sqliteTable('vulse_form_unique_values', {
140
+ formHandle: text('form_handle').notNull().references(() => vulseForms.handle, { onDelete: 'cascade' }),
141
+ fieldName: text('field_name').notNull(),
142
+ valueHash: text('value_hash').notNull(),
143
+ submissionId: text('submission_id').notNull().references(() => vulseFormSubmissions.id, { onDelete: 'cascade' }),
144
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
145
+ }, (t) => ({
146
+ pk: uniqueIndex('vulse_form_unique_values_pk').on(t.formHandle, t.fieldName, t.valueHash),
147
+ bySubmission: index('vulse_form_unique_values_submission').on(t.submissionId),
148
+ }))
149
+
150
+ export const vulseFormRateLimits = sqliteTable('vulse_form_rate_limits', {
151
+ formHandle: text('form_handle').notNull(),
152
+ ipHash: text('ip_hash').notNull(),
153
+ windowStart: integer('window_start', { mode: 'timestamp_ms' }).notNull(),
154
+ count: integer('count').notNull().default(1),
155
+ }, (t) => ({
156
+ pk: uniqueIndex('vulse_form_rate_limits_pk').on(t.formHandle, t.ipHash, t.windowStart),
157
+ }))
158
+
159
+ // --- Globals ---
160
+
161
+ export const vulseGlobalSets = sqliteTable('vulse_global_sets', {
162
+ handle: text('handle').primaryKey(),
163
+ label: text('label').notNull(),
164
+ definition: text('definition', { mode: 'json' }).notNull(),
165
+ blueprintHash: text('blueprint_hash').notNull().default(''),
166
+ schemaVersion: integer('schema_version').notNull().default(1),
167
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
168
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
169
+ })
170
+
171
+ export const vulseGlobalValues = sqliteTable('vulse_global_values', {
172
+ handle: text('handle').primaryKey().references(() => vulseGlobalSets.handle, { onDelete: 'cascade' }),
173
+ content: text('content', { mode: 'json' }).notNull().default({}),
174
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
175
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
176
+ })
177
+
178
+ // --- Live preview ---
179
+
180
+ export const vulsePreviewSessions = sqliteTable('vulse_preview_sessions', {
181
+ id: text('id').primaryKey(),
182
+ userId: text('user_id').notNull(),
183
+ entryId: text('entry_id'),
184
+ collection: text('collection').notNull(),
185
+ locale: text('locale').notNull().default('default'),
186
+ slug: text('slug').notNull(),
187
+ content: text('content', { mode: 'json' }).notNull(),
188
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
189
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
190
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
191
+ }, (t) => ({
192
+ byExpires: index('vulse_preview_sessions_expires').on(t.expiresAt),
193
+ byUser: index('vulse_preview_sessions_user').on(t.userId),
194
+ }))
195
+
196
+ // --- Better Auth (canonical column names per better-auth Drizzle adapter) ---
197
+
198
+ export const user = sqliteTable('user', {
199
+ id: text('id').primaryKey(),
200
+ name: text('name').notNull(),
201
+ email: text('email').notNull().unique(),
202
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
203
+ image: text('image'),
204
+ role: text('role', { enum: ['admin', 'editor', 'member'] }).notNull().default('member'),
205
+ displayName: text('display_name'),
206
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
207
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
208
+ })
209
+
210
+ export const session = sqliteTable('session', {
211
+ id: text('id').primaryKey(),
212
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
213
+ token: text('token').notNull().unique(),
214
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
215
+ ipAddress: text('ip_address'),
216
+ userAgent: text('user_agent'),
217
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
218
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
219
+ })
220
+
221
+ export const account = sqliteTable('account', {
222
+ id: text('id').primaryKey(),
223
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
224
+ accountId: text('account_id').notNull(),
225
+ providerId: text('provider_id').notNull(),
226
+ accessToken: text('access_token'),
227
+ refreshToken: text('refresh_token'),
228
+ idToken: text('id_token'),
229
+ accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
230
+ refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
231
+ scope: text('scope'),
232
+ password: text('password'),
233
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
234
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
235
+ })
236
+
237
+ export const verification = sqliteTable('verification', {
238
+ id: text('id').primaryKey(),
239
+ identifier: text('identifier').notNull(),
240
+ value: text('value').notNull(),
241
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
242
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
243
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
244
+ })
@@ -0,0 +1,12 @@
1
+ import type { z } from 'astro/zod'
2
+ import { compileFieldObject } from '../blueprints/compile.js'
3
+ import type { SetDefinition } from './definition.js'
4
+
5
+ export interface CompiledSet {
6
+ definition: SetDefinition
7
+ schema: z.ZodObject<z.ZodRawShape>
8
+ }
9
+
10
+ export function compileSet(def: SetDefinition): CompiledSet {
11
+ return { definition: def, schema: compileFieldObject(def.fields) }
12
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'astro/zod'
2
+ import { NestedFieldDefinitionSchema } from '../blueprints/definition.js'
3
+
4
+ export const SetDefinitionSchema = z.object({
5
+ handle: z.string().regex(/^[a-z][a-z0-9_-]*$/),
6
+ label: z.string().min(1),
7
+ fields: z.array(NestedFieldDefinitionSchema).min(1),
8
+ })
9
+
10
+ export type SetDefinition = z.infer<typeof SetDefinitionSchema>
@@ -0,0 +1,82 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import type { VulseDb } from '../db.js'
3
+ import { vulseSets } from '../schema.js'
4
+ import { ValidationError } from '../errors.js'
5
+ import { type SetDefinition, SetDefinitionSchema } from './definition.js'
6
+
7
+ export interface SetDTO extends SetDefinition {
8
+ createdAt: string
9
+ updatedAt: string
10
+ }
11
+
12
+ function parseRow(row: {
13
+ handle: string
14
+ definition: unknown
15
+ createdAt: Date
16
+ updatedAt: Date
17
+ }): SetDTO {
18
+ const def = SetDefinitionSchema.parse(row.definition)
19
+ return {
20
+ ...def,
21
+ createdAt: row.createdAt.toISOString(),
22
+ updatedAt: row.updatedAt.toISOString(),
23
+ }
24
+ }
25
+
26
+ export async function createSet(db: VulseDb, input: SetDefinition): Promise<SetDTO> {
27
+ const parsed = SetDefinitionSchema.safeParse(input)
28
+ if (!parsed.success) throw new ValidationError('Invalid set', { issues: parsed.error.issues })
29
+ const def = parsed.data
30
+ const now = new Date()
31
+ await db.insert(vulseSets).values({
32
+ handle: def.handle,
33
+ label: def.label,
34
+ definition: def,
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ })
38
+ const created = await getSet(db, def.handle)
39
+ if (!created) throw new Error(`set not found after create: ${def.handle}`)
40
+ return created
41
+ }
42
+
43
+ export async function listSets(db: VulseDb): Promise<SetDTO[]> {
44
+ const rows = await db.select().from(vulseSets).orderBy(vulseSets.createdAt)
45
+ return rows.map(parseRow)
46
+ }
47
+
48
+ export async function getSet(db: VulseDb, handle: string): Promise<SetDTO | null> {
49
+ const row = await db.select().from(vulseSets).where(eq(vulseSets.handle, handle)).get()
50
+ return row ? parseRow(row) : null
51
+ }
52
+
53
+ export async function updateSet(db: VulseDb, handle: string, input: SetDefinition): Promise<SetDTO> {
54
+ if (input.handle !== handle) {
55
+ throw new Error(`set handle is immutable (got '${input.handle}', expected '${handle}')`)
56
+ }
57
+ const parsed = SetDefinitionSchema.safeParse(input)
58
+ if (!parsed.success) throw new ValidationError('Invalid set', { issues: parsed.error.issues })
59
+ const def = parsed.data
60
+ await db.update(vulseSets).set({
61
+ label: def.label,
62
+ definition: def,
63
+ updatedAt: new Date(),
64
+ }).where(eq(vulseSets.handle, handle))
65
+ const out = await getSet(db, handle)
66
+ if (!out) throw new Error(`set not found: ${handle}`)
67
+ return out
68
+ }
69
+
70
+ export async function deleteSet(db: VulseDb, handle: string): Promise<void> {
71
+ await db.delete(vulseSets).where(eq(vulseSets.handle, handle))
72
+ }
73
+
74
+ export async function loadCompiledSets(db: VulseDb): Promise<Map<string, import('./compile.js').CompiledSet>> {
75
+ const { compileSet } = await import('./compile.js')
76
+ const rows = await listSets(db)
77
+ const map = new Map<string, import('./compile.js').CompiledSet>()
78
+ for (const row of rows) {
79
+ map.set(row.handle, compileSet(row))
80
+ }
81
+ return map
82
+ }
@@ -0,0 +1,57 @@
1
+ import type { z } from 'astro/zod'
2
+ import type { CompiledSet } from './compile.js'
3
+
4
+ interface PMNode {
5
+ type?: unknown
6
+ attrs?: unknown
7
+ content?: unknown
8
+ }
9
+
10
+ function isNode(v: unknown): v is PMNode {
11
+ return typeof v === 'object' && v !== null && 'type' in v
12
+ }
13
+
14
+ export function validateSetNodes(
15
+ doc: unknown,
16
+ basePath: (string | number)[],
17
+ sets: Map<string, CompiledSet>,
18
+ ctx: z.core.$RefinementCtx,
19
+ ): void {
20
+ if (!isNode(doc)) return
21
+ walk(doc, basePath)
22
+
23
+ function walk(node: PMNode, path: (string | number)[]): void {
24
+ if (node.type === 'vulseSet') {
25
+ const attrs = (node.attrs ?? {}) as Record<string, unknown>
26
+ const handle = typeof attrs.set === 'string' ? attrs.set : undefined
27
+ const data = (attrs.data ?? {}) as unknown
28
+ const compiled = handle ? sets.get(handle) : undefined
29
+
30
+ if (!compiled) {
31
+ ctx.addIssue({
32
+ code: 'custom',
33
+ path: [...path, 'set'],
34
+ message: `unknown set: ${handle ?? '(empty)'}`,
35
+ })
36
+ return
37
+ }
38
+
39
+ const parsed = compiled.schema.safeParse(data)
40
+ if (!parsed.success) {
41
+ for (const issue of parsed.error.issues) {
42
+ ctx.addIssue({
43
+ ...issue,
44
+ path: [...path, 'data', ...issue.path],
45
+ })
46
+ }
47
+ }
48
+ return
49
+ }
50
+
51
+ if (Array.isArray(node.content)) {
52
+ node.content.forEach((child: unknown, i: number) => {
53
+ if (isNode(child)) walk(child, [...path, 'content', i])
54
+ })
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,10 @@
1
+ function bytesToHex(bytes: Uint8Array): string {
2
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
3
+ }
4
+
5
+ /** Workers-safe SHA-256 hex digest (no node:crypto). */
6
+ export async function sha256Hex(input: string): Promise<string> {
7
+ const data = new TextEncoder().encode(input)
8
+ const hash = await crypto.subtle.digest('SHA-256', data)
9
+ return bytesToHex(new Uint8Array(hash))
10
+ }
@@ -0,0 +1,30 @@
1
+ const VALID_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
2
+
3
+ export function normalizeSlug(input: string): string {
4
+ return input
5
+ .normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .replace(/-{2,}/g, '-')
10
+ }
11
+
12
+ export function isValidSlug(input: string): boolean {
13
+ return VALID_SLUG.test(input)
14
+ }
15
+
16
+ const VALID_FIELD_HANDLE = /^[a-zA-Z_][a-zA-Z0-9_]*$/
17
+
18
+ export function normalizeFieldHandle(input: string): string {
19
+ return input
20
+ .normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9_]+/g, '_')
23
+ .replace(/^_+|_+$/g, '')
24
+ .replace(/_+/g, '_')
25
+ .replace(/^[^a-z_]+/, '')
26
+ }
27
+
28
+ export function isValidFieldHandle(input: string): boolean {
29
+ return VALID_FIELD_HANDLE.test(input)
30
+ }
@@ -0,0 +1,83 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
+ import {
4
+ type CollectionScaffoldInput,
5
+ generateCollectionScaffoldFiles,
6
+ patchContentConfig,
7
+ } from './collection.js'
8
+
9
+ export interface WriteCollectionScaffoldOptions {
10
+ force?: boolean
11
+ skipBlueprint?: boolean
12
+ skipPages?: boolean
13
+ skipContentConfig?: boolean
14
+ }
15
+
16
+ export interface WriteCollectionScaffoldResult {
17
+ written: string[]
18
+ skipped: string[]
19
+ patched: string[]
20
+ }
21
+
22
+ async function exists(path: string): Promise<boolean> {
23
+ try {
24
+ await access(path)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ export async function writeCollectionScaffold(
32
+ cwd: string,
33
+ input: CollectionScaffoldInput,
34
+ opts: WriteCollectionScaffoldOptions = {},
35
+ ): Promise<WriteCollectionScaffoldResult> {
36
+ const written: string[] = []
37
+ const skipped: string[] = []
38
+ const patched: string[] = []
39
+
40
+ const files = generateCollectionScaffoldFiles(input, {
41
+ includeBlueprint: !opts.skipBlueprint,
42
+ includeContentConfig: false,
43
+ includeIndex: !opts.skipPages && !!input.indexRoute?.trim(),
44
+ }).filter((file) => !opts.skipPages || !file.path.startsWith('src/pages/'))
45
+
46
+ for (const file of files) {
47
+ const abs = join(cwd, file.path)
48
+ if (await exists(abs) && !opts.force) {
49
+ skipped.push(file.path)
50
+ continue
51
+ }
52
+ await mkdir(dirname(abs), { recursive: true })
53
+ await writeFile(abs, file.content, 'utf8')
54
+ written.push(file.path)
55
+ }
56
+
57
+ if (!opts.skipContentConfig) {
58
+ const configPath = join(cwd, 'src/content.config.ts')
59
+ if (await exists(configPath)) {
60
+ const existing = await readFile(configPath, 'utf8')
61
+ const next = patchContentConfig(existing, input)
62
+ if (next !== existing) {
63
+ await writeFile(configPath, next, 'utf8')
64
+ patched.push('src/content.config.ts')
65
+ } else if (existing.includes(`${input.handle}:`)) {
66
+ skipped.push('src/content.config.ts (already configured)')
67
+ }
68
+ } else {
69
+ const content = generateCollectionScaffoldFiles(input, {
70
+ includeBlueprint: false,
71
+ includeContentConfig: true,
72
+ includeIndex: false,
73
+ })[0]
74
+ if (content) {
75
+ await mkdir(dirname(configPath), { recursive: true })
76
+ await writeFile(configPath, content.content, 'utf8')
77
+ written.push('src/content.config.ts')
78
+ }
79
+ }
80
+ }
81
+
82
+ return { written, skipped, patched }
83
+ }