@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.
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -4
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +11 -10
- package/dist/core/blueprints/compile.d.ts +1 -1
- package/dist/core/blueprints/compile.d.ts.map +1 -1
- package/dist/core/blueprints/compile.js +3 -3
- package/dist/core/blueprints/mutations.js +2 -2
- package/dist/core/forms/rate-limit.d.ts +1 -1
- package/dist/core/forms/rate-limit.d.ts.map +1 -1
- package/dist/core/forms/rate-limit.js +3 -3
- package/dist/core/forms/unique.d.ts +1 -1
- package/dist/core/forms/unique.d.ts.map +1 -1
- package/dist/core/forms/unique.js +4 -4
- package/dist/core/globals/compile.d.ts +1 -1
- package/dist/core/globals/compile.d.ts.map +1 -1
- package/dist/core/globals/compile.js +2 -2
- package/dist/core/globals/definition.d.ts +1 -1
- package/dist/core/globals/definition.d.ts.map +1 -1
- package/dist/core/globals/definition.js +3 -3
- package/dist/core/repos/globals.js +3 -3
- package/dist/core/sha256.d.ts +3 -0
- package/dist/core/sha256.d.ts.map +1 -0
- package/dist/core/sha256.js +9 -0
- package/dist/integration/index.js +1 -1
- package/dist/integration/install-hook.d.ts.map +1 -1
- package/dist/integration/install-hook.js +6 -4
- package/dist/integration/wrangler-config.d.ts +12 -0
- package/dist/integration/wrangler-config.d.ts.map +1 -0
- package/dist/integration/wrangler-config.js +97 -0
- package/dist/integration/wrangler-patch.d.ts +1 -0
- package/dist/integration/wrangler-patch.d.ts.map +1 -1
- package/dist/integration/wrangler-patch.js +2 -1
- package/dist/server/routes/form-submit.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/src/admin/assets/logo-mark.svg +5 -0
- package/src/admin/client/active-locale.ts +17 -0
- package/src/admin/client/api.ts +21 -0
- package/src/admin/client/form-from-zod.ts +7 -0
- package/src/admin/client/live-preview-enabled.ts +5 -0
- package/src/admin/components/AdminShell.astro +45 -0
- package/src/admin/components/AuthSettings.vue +60 -0
- package/src/admin/components/BlockEditor.vue +53 -0
- package/src/admin/components/BlueprintEditor.vue +1783 -0
- package/src/admin/components/CollectionKindIcon.vue +26 -0
- package/src/admin/components/CollectionTree.vue +220 -0
- package/src/admin/components/EntryEditorWithPreview.vue +130 -0
- package/src/admin/components/EntryForm.vue +411 -0
- package/src/admin/components/EntryList.vue +121 -0
- package/src/admin/components/EntryStatusBadge.vue +24 -0
- package/src/admin/components/FormEditor.vue +233 -0
- package/src/admin/components/FormList.vue +54 -0
- package/src/admin/components/GlobalSetEditor.vue +272 -0
- package/src/admin/components/GlobalSetList.vue +55 -0
- package/src/admin/components/LivePreviewPanel.vue +171 -0
- package/src/admin/components/LoginForm.vue +53 -0
- package/src/admin/components/MediaLibrary.vue +106 -0
- package/src/admin/components/MediaPicker.vue +49 -0
- package/src/admin/components/RevisionDiff.vue +11 -0
- package/src/admin/components/RevisionList.vue +134 -0
- package/src/admin/components/SeoFields.vue +113 -0
- package/src/admin/components/SetEditor.vue +137 -0
- package/src/admin/components/SetList.vue +32 -0
- package/src/admin/components/SettingsForm.vue +189 -0
- package/src/admin/components/SideNav.vue +152 -0
- package/src/admin/components/SubmissionDetail.vue +45 -0
- package/src/admin/components/SubmissionList.vue +89 -0
- package/src/admin/components/ToastHost.vue +33 -0
- package/src/admin/components/TreeRow.vue +163 -0
- package/src/admin/components/UserEditor.vue +186 -0
- package/src/admin/components/UserList.vue +46 -0
- package/src/admin/components/blocks/BlockItem.vue +32 -0
- package/src/admin/components/blocks/BlockToolbar.vue +12 -0
- package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
- package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
- package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
- package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
- package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
- package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
- package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
- package/src/admin/components/fields/BlocksField.vue +123 -0
- package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
- package/src/admin/components/fields/BoolField.vue +10 -0
- package/src/admin/components/fields/DateField.vue +22 -0
- package/src/admin/components/fields/EntriesField.vue +153 -0
- package/src/admin/components/fields/EntryField.vue +138 -0
- package/src/admin/components/fields/EnumField.vue +81 -0
- package/src/admin/components/fields/FieldRenderer.vue +87 -0
- package/src/admin/components/fields/GridField.vue +173 -0
- package/src/admin/components/fields/LinkField.vue +219 -0
- package/src/admin/components/fields/MediaField.vue +69 -0
- package/src/admin/components/fields/NumberField.vue +12 -0
- package/src/admin/components/fields/ObjectField.vue +18 -0
- package/src/admin/components/fields/RefField.vue +170 -0
- package/src/admin/components/fields/RepeaterField.vue +27 -0
- package/src/admin/components/fields/ReplicatorField.vue +121 -0
- package/src/admin/components/fields/TextField.vue +11 -0
- package/src/admin/components/fields/TextareaField.vue +11 -0
- package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
- package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
- package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
- package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
- package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
- package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
- package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
- package/src/admin/components/fields/emoji-extension.ts +54 -0
- package/src/admin/components/fields/link-extension.ts +48 -0
- package/src/admin/components/fields/set-node-utils.ts +115 -0
- package/src/admin/components/fields/url-utils.ts +85 -0
- package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
- package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
- package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
- package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
- package/src/admin/components/fields/vulse-set-extension.ts +66 -0
- package/src/admin/components/fields/vulse-video-extension.ts +65 -0
- package/src/admin/composables/toast.ts +35 -0
- package/src/admin/composables/useEntrySearch.ts +112 -0
- package/src/admin/composables/useSets.ts +31 -0
- package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
- package/src/admin/pages/collections/[name]/[id].astro +90 -0
- package/src/admin/pages/collections/[name]/index.astro +31 -0
- package/src/admin/pages/collections/[name]/new.astro +38 -0
- package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
- package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
- package/src/admin/pages/forms/[handle].astro +9 -0
- package/src/admin/pages/forms/index.astro +7 -0
- package/src/admin/pages/forms/new.astro +7 -0
- package/src/admin/pages/index.astro +36 -0
- package/src/admin/pages/login.astro +14 -0
- package/src/admin/pages/media.astro +8 -0
- package/src/admin/pages/schema/[handle].astro +10 -0
- package/src/admin/pages/schema/new.astro +9 -0
- package/src/admin/pages/settings/auth.astro +9 -0
- package/src/admin/pages/settings/globals/[handle].astro +9 -0
- package/src/admin/pages/settings/globals/index.astro +7 -0
- package/src/admin/pages/settings/globals/new.astro +7 -0
- package/src/admin/pages/settings/index.astro +8 -0
- package/src/admin/pages/settings/sets/[handle].astro +9 -0
- package/src/admin/pages/settings/sets/index.astro +7 -0
- package/src/admin/pages/settings/sets/new.astro +7 -0
- package/src/admin/pages/users/[id].astro +10 -0
- package/src/admin/pages/users/index.astro +8 -0
- package/src/admin/styles/admin.css +166 -0
- package/src/core/access.ts +9 -0
- package/src/core/blocks/schema.ts +66 -0
- package/src/core/blueprints/code-to-definition.ts +156 -0
- package/src/core/blueprints/compile.ts +176 -0
- package/src/core/blueprints/define.ts +12 -0
- package/src/core/blueprints/definition.ts +185 -0
- package/src/core/blueprints/load.ts +144 -0
- package/src/core/blueprints/mutations.ts +236 -0
- package/src/core/blueprints/preview-path.ts +33 -0
- package/src/core/blueprints/reflect-fields.ts +305 -0
- package/src/core/blueprints/registry.ts +14 -0
- package/src/core/blueprints/seed.ts +20 -0
- package/src/core/blueprints/select-helpers.ts +30 -0
- package/src/core/blueprints/seo.ts +180 -0
- package/src/core/blueprints/types.ts +59 -0
- package/src/core/blueprints/zod-helpers.ts +86 -0
- package/src/core/db.ts +11 -0
- package/src/core/errors.ts +34 -0
- package/src/core/forms/compile.ts +84 -0
- package/src/core/forms/definition.ts +102 -0
- package/src/core/forms/rate-limit.ts +52 -0
- package/src/core/forms/unique.ts +38 -0
- package/src/core/globals/compile.ts +35 -0
- package/src/core/globals/definition.ts +27 -0
- package/src/core/locales.ts +45 -0
- package/src/core/migrations.ts +48 -0
- package/src/core/parse-content.ts +85 -0
- package/src/core/plugins/definition.ts +150 -0
- package/src/core/preview-content.ts +21 -0
- package/src/core/repos/entries.ts +504 -0
- package/src/core/repos/forms.ts +270 -0
- package/src/core/repos/globals.ts +179 -0
- package/src/core/repos/media.ts +106 -0
- package/src/core/repos/preview-sessions.ts +108 -0
- package/src/core/repos/revisions.ts +60 -0
- package/src/core/repos/settings.ts +23 -0
- package/src/core/schema.ts +244 -0
- package/src/core/sets/compile.ts +12 -0
- package/src/core/sets/definition.ts +10 -0
- package/src/core/sets/service.ts +82 -0
- package/src/core/sets/validate-tree.ts +57 -0
- package/src/core/sha256.ts +10 -0
- package/src/core/slug.ts +30 -0
- package/src/scaffold/collection-write.ts +83 -0
- package/src/scaffold/collection.ts +277 -0
- package/src/server/assets/live-preview-bridge.content.ts +2 -0
- package/src/server/assets/live-preview-bridge.js +535 -0
- package/src/server/better-auth.ts +82 -0
- package/src/server/cf-images.ts +34 -0
- package/src/server/cron.ts +37 -0
- package/src/server/email.ts +17 -0
- package/src/server/endpoints/api-auth.ts +10 -0
- package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
- package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
- package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
- package/src/server/endpoints/api-vulse-entries.ts +23 -0
- package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
- package/src/server/endpoints/api-vulse-form-public.ts +7 -0
- package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
- package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
- package/src/server/endpoints/api-vulse-forms.ts +12 -0
- package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
- package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
- package/src/server/endpoints/api-vulse-globals.ts +12 -0
- package/src/server/endpoints/api-vulse-media-file.ts +7 -0
- package/src/server/endpoints/api-vulse-media-id.ts +12 -0
- package/src/server/endpoints/api-vulse-media.ts +12 -0
- package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
- package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
- package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions.ts +7 -0
- package/src/server/endpoints/api-vulse-search.ts +7 -0
- package/src/server/endpoints/api-vulse-sets.ts +23 -0
- package/src/server/endpoints/api-vulse-settings.ts +12 -0
- package/src/server/endpoints/api-vulse-users-id.ts +12 -0
- package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
- package/src/server/endpoints/api-vulse-users-role.ts +9 -0
- package/src/server/endpoints/api-vulse-users.ts +7 -0
- package/src/server/endpoints/with-runtime.ts +11 -0
- package/src/server/env.ts +23 -0
- package/src/server/envelope.ts +21 -0
- package/src/server/forms/email.ts +11 -0
- package/src/server/forms/process-submission.ts +95 -0
- package/src/server/forms/queue.ts +25 -0
- package/src/server/forms/templates.ts +24 -0
- package/src/server/forms/webhook.ts +19 -0
- package/src/server/handler.ts +66 -0
- package/src/server/image-probe.ts +35 -0
- package/src/server/loader.ts +54 -0
- package/src/server/plugins.ts +214 -0
- package/src/server/preview.ts +25 -0
- package/src/server/r2.ts +13 -0
- package/src/server/routes/blueprints.ts +62 -0
- package/src/server/routes/entries.ts +255 -0
- package/src/server/routes/form-submit.ts +168 -0
- package/src/server/routes/form-upload.ts +100 -0
- package/src/server/routes/forms.ts +88 -0
- package/src/server/routes/globals-public.ts +30 -0
- package/src/server/routes/globals.ts +93 -0
- package/src/server/routes/media.ts +145 -0
- package/src/server/routes/preview-sessions.ts +76 -0
- package/src/server/routes/preview.ts +36 -0
- package/src/server/routes/revisions.ts +29 -0
- package/src/server/routes/search.ts +31 -0
- package/src/server/routes/sets.ts +40 -0
- package/src/server/routes/settings.ts +24 -0
- package/src/server/routes/users.ts +127 -0
- package/src/server/runtime.ts +99 -0
- package/src/server/sdk/collections.ts +98 -0
- package/src/server/sdk/index.ts +25 -0
- package/src/server/sdk/media.ts +11 -0
- 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
|
+
}
|