@elytracms/studio 0.0.86 → 0.0.88
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/convex/asset-revalidate.test.ts +186 -0
- package/convex/assets.ts +53 -19
- package/convex/cms.ts +22 -0
- package/convex/delivery.ts +33 -116
- package/convex/functions.ts +3 -3
- package/convex/rail-write-parity.test.ts +180 -0
- package/convex/redirects.ts +33 -11
- package/convex/revalidate.ts +7 -5
- package/convex/triggers.ts +53 -3
- package/dist/components/ai/ai-chat-panel.js +8 -1
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +20 -20
- package/dist/components/app-views/default-modules.js +27 -48
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +5 -6
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +28 -19
- package/dist/components/assets/asset-metadata.js +24 -31
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-selection.d.ts +24 -5
- package/dist/components/assets/asset-selection.js +39 -9
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +17 -14
- package/dist/components/assets/assets-collection-view-source.js +25 -31
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +11 -1
- package/dist/components/assets/assets-store.js +10 -0
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/focal-point.d.ts +0 -7
- package/dist/components/assets/focal-point.js +0 -10
- package/dist/components/assets/focal-point.js.map +1 -1
- package/dist/components/builder/blocks-panel.js +3 -3
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +25 -1
- package/dist/components/builder/builder-store.js +3 -2
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.js +0 -17
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-chrome.d.ts +10 -12
- package/dist/components/builder/canvas-chrome.js +12 -13
- package/dist/components/builder/canvas-chrome.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +8 -0
- package/dist/components/builder/canvas-dnd.js +5 -3
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +23 -24
- package/dist/components/builder/canvas-render-context.js +35 -60
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas.js +5 -11
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +14 -6
- package/dist/components/builder/clipboard-actions.js +29 -20
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +19 -26
- package/dist/components/builder/composition-field-editor.js +30 -52
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +3 -1
- package/dist/components/builder/insert-context.js +15 -10
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +29 -0
- package/dist/components/builder/page-tree.js +56 -58
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/reuse.js +5 -2
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +2 -1
- package/dist/components/cms/asset-field.js +39 -83
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +77 -40
- package/dist/components/cms/asset-picker-core.js +73 -46
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.d.ts +48 -19
- package/dist/components/cms/asset-picker.js +69 -30
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +3 -1
- package/dist/components/cms/cell-edit.js +2 -2
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-composition-validators.js +11 -2
- package/dist/components/cms/cms-composition-validators.js.map +1 -1
- package/dist/components/cms/cms-store.js +15 -23
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/editable-cell.js +3 -3
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +27 -2
- package/dist/components/cms/gate-validation.js +19 -1
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/rich-text/editor.js +2 -2
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +7 -0
- package/dist/components/cms/rich-text/insert.js +7 -0
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/use-document-versions.js +1 -0
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/versions-model.d.ts +0 -12
- package/dist/components/cms/versions-model.js +3 -15
- package/dist/components/cms/versions-model.js.map +1 -1
- package/dist/components/components/component-detail.js +1 -1
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +2 -4
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +13 -10
- package/dist/components/components/transfer.js +21 -19
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -3
- package/dist/components/nav-issues.js +38 -2
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/project-session.d.ts +9 -0
- package/dist/components/project-session.js +1 -1
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +17 -2
- package/dist/components/redirects/redirects-collection-view-source.js +20 -6
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/section-nav.js +26 -2
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/section-surface.js +3 -2
- package/dist/components/section-surface.js.map +1 -1
- package/dist/components/settings-form.js +1 -2
- package/dist/components/settings-form.js.map +1 -1
- package/dist/components/studio-context.js +1 -18
- package/dist/components/studio-context.js.map +1 -1
- package/dist/components/studio-project-screen.js +3 -5
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/lib/ai/apply-content.js +73 -29
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +35 -0
- package/dist/lib/ai/constraints.js +104 -4
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/route.js +4 -1
- package/dist/lib/ai/route.js.map +1 -1
- package/dist/lib/ai/shape.d.ts +3 -2
- package/dist/lib/ai/shape.js +16 -24
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +10 -4
- package/dist/lib/ai/tools.js +12 -4
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/types.d.ts +8 -0
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.js +33 -12
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/assets/asset-placements.d.ts +35 -0
- package/dist/lib/assets/asset-placements.js +74 -0
- package/dist/lib/assets/asset-placements.js.map +1 -0
- package/dist/lib/assets/backfill-thumbs.d.ts +9 -8
- package/dist/lib/assets/backfill-thumbs.js +13 -25
- package/dist/lib/assets/backfill-thumbs.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.d.ts +17 -15
- package/dist/lib/clipboard/paste-issues.js +8 -45
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/clipboard/paste-target.d.ts +25 -1
- package/dist/lib/clipboard/paste-target.js +13 -6
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms/published-documents.js +1 -0
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +3 -3
- package/dist/lib/data/collection-content.d.ts +22 -1
- package/dist/lib/data/collection-content.js +15 -0
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/data/index.d.ts +0 -3
- package/dist/lib/data/index.js +4 -6
- package/dist/lib/data/index.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +45 -2
- package/dist/lib/dnd/drop-rules.js +64 -9
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/engine-version.d.ts +3 -3
- package/dist/lib/engine-version.js +3 -3
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +9 -56
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +8 -53
- package/dist/lib/i18n/messages.en.js +14 -57
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/locale/fallback.js +8 -2
- package/dist/lib/locale/fallback.js.map +1 -1
- package/dist/lib/locale/index.d.ts +0 -1
- package/dist/lib/locale/index.js +0 -1
- package/dist/lib/locale/index.js.map +1 -1
- package/dist/lib/locale/locale-store.d.ts +10 -3
- package/dist/lib/locale/locale-store.js +12 -5
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +24 -22
- package/dist/lib/operations/session-hydration.js +15 -53
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +63 -75
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +0 -2
- package/dist/lib/persona/labels.js +1 -7
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/registry/index.d.ts +4 -2
- package/dist/lib/registry/index.js +26 -21
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/project-components.js +0 -3
- package/dist/lib/registry/project-components.js.map +1 -1
- package/dist/lib/routes/route-issues.js +4 -5
- package/dist/lib/routes/route-issues.js.map +1 -1
- package/dist/lib/theme.js +3 -2
- package/dist/lib/theme.js.map +1 -1
- package/dist/lib/webhooks/tags.js +11 -14
- package/dist/lib/webhooks/tags.js.map +1 -1
- package/dist/lib/workspace/config-issues.d.ts +6 -0
- package/dist/lib/workspace/config-issues.js +8 -4
- package/dist/lib/workspace/config-issues.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +2 -2
- package/dist/lib/workspace/index.js +2 -2
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/previews/enumerate.d.ts +27 -9
- package/dist/previews/enumerate.js +27 -6
- package/dist/previews/enumerate.js.map +1 -1
- package/dist/previews/manifest-format.d.ts +9 -0
- package/dist/previews/manifest-format.js.map +1 -1
- package/dist/router/match.d.ts +0 -2
- package/dist/router/match.js +0 -2
- package/dist/router/match.js.map +1 -1
- package/dist/router/navigate-once.d.ts +19 -4
- package/dist/router/navigate-once.js +22 -5
- package/dist/router/navigate-once.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/package.json +3 -3
- package/src/styles/studio.css +5 -4
- package/src/styles/tokens.css +72 -55
- package/convex/content.ts +0 -291
- package/dist/components/collab/collab-context.d.ts +0 -27
- package/dist/components/collab/collab-context.js +0 -39
- package/dist/components/collab/collab-context.js.map +0 -1
- package/dist/components/collab/collab-seed.d.ts +0 -15
- package/dist/components/collab/collab-seed.js +0 -20
- package/dist/components/collab/collab-seed.js.map +0 -1
- package/dist/components/extensions/extensions-view.d.ts +0 -7
- package/dist/components/extensions/extensions-view.js +0 -127
- package/dist/components/extensions/extensions-view.js.map +0 -1
- package/dist/lib/bindings/section-bindings.d.ts +0 -24
- package/dist/lib/bindings/section-bindings.js +0 -9
- package/dist/lib/bindings/section-bindings.js.map +0 -1
- package/dist/lib/data/data-store.d.ts +0 -98
- package/dist/lib/data/data-store.js +0 -211
- package/dist/lib/data/data-store.js.map +0 -1
- package/dist/lib/data/providers.d.ts +0 -143
- package/dist/lib/data/providers.js +0 -255
- package/dist/lib/data/providers.js.map +0 -1
- package/dist/lib/data/sample-capture.d.ts +0 -30
- package/dist/lib/data/sample-capture.js +0 -70
- package/dist/lib/data/sample-capture.js.map +0 -1
- package/dist/lib/data/sources-store.d.ts +0 -42
- package/dist/lib/data/sources-store.js +0 -120
- package/dist/lib/data/sources-store.js.map +0 -1
- package/dist/lib/locale/binding-fallback.d.ts +0 -35
- package/dist/lib/locale/binding-fallback.js +0 -73
- package/dist/lib/locale/binding-fallback.js.map +0 -1
- package/dist/lib/starters/kit-session.d.ts +0 -5
- package/dist/lib/starters/kit-session.js +0 -17
- package/dist/lib/starters/kit-session.js.map +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import schema from './schema'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EC-579 (no-owner-call carve-out) — an asset edit must reach the live site.
|
|
8
|
+
*
|
|
9
|
+
* An asset row rides the delivery wire field by field (`delivery.ts`: url, alt,
|
|
10
|
+
* dimensions, focal point, lqip, the `meta` bag), so editing one changes
|
|
11
|
+
* published output with no draft/published split to gate on. Before this ticket
|
|
12
|
+
* the EC-286 write chokepoint registered `cmsDocuments` and `redirects` but not
|
|
13
|
+
* `assets`, so on the Convex path an alt/focal-point/re-upload edit reached the
|
|
14
|
+
* host by no server-side path (EC-566 M5, measured by the Codex review as P07).
|
|
15
|
+
*
|
|
16
|
+
* Same harness as `redirect-revalidate.test.ts`: the REAL `assets:upsert` /
|
|
17
|
+
* `assets:remove` mutations through the REAL `functions.ts` trigger wrapper on
|
|
18
|
+
* `convex-test`'s in-memory backend, asserting on the `_scheduled_functions`
|
|
19
|
+
* system table — the observable `scheduleRevalidate` produces. A redirect write
|
|
20
|
+
* is the known-positive control that this instrument sees a revalidation at all.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const modules = {
|
|
24
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
25
|
+
'./_generated/api.ts': async () => ({}),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
|
|
29
|
+
const upsertAsset = makeFunctionReference<'mutation'>('assets:upsert')
|
|
30
|
+
const removeAsset = makeFunctionReference<'mutation'>('assets:remove')
|
|
31
|
+
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
32
|
+
|
|
33
|
+
const SESSION_USER_ID = 'user_admin'
|
|
34
|
+
const SCOPE = 'ec579-scope'
|
|
35
|
+
const SWEEP = [`p:${SCOPE}:project`]
|
|
36
|
+
|
|
37
|
+
async function freshBackend() {
|
|
38
|
+
const t = convexTest(schema, modules)
|
|
39
|
+
const now = new Date().toISOString()
|
|
40
|
+
await t.run(async (ctx) => {
|
|
41
|
+
await ctx.db.insert('members', {
|
|
42
|
+
userId: SESSION_USER_ID,
|
|
43
|
+
role: 'admin',
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
return t
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The scheduled `webhooks:deliver` calls, in order, as `{event, tags}`. */
|
|
52
|
+
async function scheduledRevalidations(
|
|
53
|
+
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
54
|
+
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
55
|
+
return t.run(async (ctx) => {
|
|
56
|
+
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
57
|
+
return rows
|
|
58
|
+
.filter((row) => row.name.startsWith('webhooks'))
|
|
59
|
+
.map((row) => {
|
|
60
|
+
const [args] = row.args as [{ event?: unknown; tags?: unknown }]
|
|
61
|
+
return { event: args?.event, tags: args?.tags }
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function withScope<T>(scope: string | undefined, run: () => Promise<T>): Promise<T> {
|
|
67
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
|
68
|
+
?.env
|
|
69
|
+
const previous = env?.['CANVAS_PROJECT_SCOPE']
|
|
70
|
+
if (env) {
|
|
71
|
+
if (scope === undefined) delete env['CANVAS_PROJECT_SCOPE']
|
|
72
|
+
else env['CANVAS_PROJECT_SCOPE'] = scope
|
|
73
|
+
}
|
|
74
|
+
return run().finally(() => {
|
|
75
|
+
if (!env) return
|
|
76
|
+
if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
|
|
77
|
+
else env['CANVAS_PROJECT_SCOPE'] = previous
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STAMP = '2026-09-26T00:00:00.000Z'
|
|
82
|
+
|
|
83
|
+
const assetArgs = {
|
|
84
|
+
assetId: 'asset_ec579_1',
|
|
85
|
+
filename: 'photo.png',
|
|
86
|
+
contentType: 'image/png',
|
|
87
|
+
byteSize: 1,
|
|
88
|
+
storageKey: 'placeholder',
|
|
89
|
+
url: 'https://assets.invalid/photo.png',
|
|
90
|
+
alt: 'old',
|
|
91
|
+
createdAt: STAMP,
|
|
92
|
+
updatedAt: STAMP,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('EC-579 — the write chokepoint revalidates on every asset edit', () => {
|
|
96
|
+
it('an alt edit schedules a project sweep; the create before it schedules nothing', async () => {
|
|
97
|
+
await withScope(SCOPE, async () => {
|
|
98
|
+
const t = await freshBackend()
|
|
99
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
100
|
+
|
|
101
|
+
// Known-positive control: the same instrument DOES see a revalidation.
|
|
102
|
+
await asAdmin.mutation(saveRedirect, {
|
|
103
|
+
redirectId: 'redirect_ec579_1',
|
|
104
|
+
from: '/old',
|
|
105
|
+
to: '/new',
|
|
106
|
+
permanent: true,
|
|
107
|
+
enabled: true,
|
|
108
|
+
createdAt: STAMP,
|
|
109
|
+
updatedAt: STAMP,
|
|
110
|
+
})
|
|
111
|
+
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
112
|
+
|
|
113
|
+
// A brand-new asset is on no page yet, so its create invalidates nothing —
|
|
114
|
+
// the same rule the browser dispatcher applies (`webhooks/tags.ts`).
|
|
115
|
+
await asAdmin.mutation(upsertAsset, assetArgs)
|
|
116
|
+
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
117
|
+
|
|
118
|
+
await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
|
|
119
|
+
|
|
120
|
+
// The edit IS live output: the published snapshot already serves it.
|
|
121
|
+
const snapshot = (await t.query(deliverySnapshot, { projectId: SCOPE })) as {
|
|
122
|
+
assets: { alt?: string }[]
|
|
123
|
+
}
|
|
124
|
+
expect(snapshot.assets[0]?.alt).toBe('new')
|
|
125
|
+
|
|
126
|
+
expect(await scheduledRevalidations(t)).toEqual([
|
|
127
|
+
{ event: 'redirects.save', tags: SWEEP },
|
|
128
|
+
{ event: 'assets.upsert', tags: SWEEP },
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('schedules a sweep on a focal-point edit, a meta edit, a re-upload, and a delete', async () => {
|
|
134
|
+
await withScope(SCOPE, async () => {
|
|
135
|
+
const t = await freshBackend()
|
|
136
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
137
|
+
|
|
138
|
+
await asAdmin.mutation(upsertAsset, assetArgs)
|
|
139
|
+
await asAdmin.mutation(upsertAsset, { ...assetArgs, focalPoint: { x: 0.2, y: 0.8 } })
|
|
140
|
+
await asAdmin.mutation(upsertAsset, { ...assetArgs, meta: { alt: 'meta alt', credit: 'Jo' } })
|
|
141
|
+
// A replace keeps the id and swaps the bytes (EC-568's case).
|
|
142
|
+
await asAdmin.mutation(upsertAsset, {
|
|
143
|
+
...assetArgs,
|
|
144
|
+
storageKey: 'placeholder-2',
|
|
145
|
+
url: 'https://assets.invalid/photo-2.png',
|
|
146
|
+
byteSize: 2,
|
|
147
|
+
})
|
|
148
|
+
await asAdmin.mutation(removeAsset, { assetId: assetArgs.assetId })
|
|
149
|
+
|
|
150
|
+
// The sweep, never an `asset:<id>` tag: a page does not carry the tag for an
|
|
151
|
+
// asset it reads through a document asset FIELD (`next/src/route-core.ts`,
|
|
152
|
+
// `scopeAssetsToPage`), so a per-asset tag would miss exactly those pages.
|
|
153
|
+
expect(await scheduledRevalidations(t)).toEqual([
|
|
154
|
+
{ event: 'assets.upsert', tags: SWEEP },
|
|
155
|
+
{ event: 'assets.upsert', tags: SWEEP },
|
|
156
|
+
{ event: 'assets.upsert', tags: SWEEP },
|
|
157
|
+
{ event: 'assets.remove', tags: SWEEP },
|
|
158
|
+
])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('removing an asset that does not exist writes nothing and schedules nothing', async () => {
|
|
163
|
+
await withScope(SCOPE, async () => {
|
|
164
|
+
const t = await freshBackend()
|
|
165
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
166
|
+
await asAdmin.mutation(removeAsset, { assetId: 'asset_never_existed' })
|
|
167
|
+
expect(await scheduledRevalidations(t)).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('stays a silent no-op on a deployment with no CANVAS_PROJECT_SCOPE', async () => {
|
|
172
|
+
await withScope(undefined, async () => {
|
|
173
|
+
const t = await freshBackend()
|
|
174
|
+
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
175
|
+
await asAdmin.mutation(upsertAsset, assetArgs)
|
|
176
|
+
await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
|
|
177
|
+
// The first test is the known-positive control: the same edit on the same
|
|
178
|
+
// harness DOES schedule when the scope is configured.
|
|
179
|
+
expect(await scheduledRevalidations(t)).toEqual([])
|
|
180
|
+
// The write itself still happened — silence is at the webhook, not the save.
|
|
181
|
+
expect(
|
|
182
|
+
await t.run(async (ctx) => (await ctx.db.query('assets').first())?.alt),
|
|
183
|
+
).toBe('new')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
package/convex/assets.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { v } from 'convex/values'
|
|
2
|
-
import type { GenericId } from 'convex/values'
|
|
1
|
+
import { ConvexError, v } from 'convex/values'
|
|
2
|
+
import type { GenericId, Validator } from 'convex/values'
|
|
3
|
+
import type { DocumentByName, WithoutSystemFields } from 'convex/server'
|
|
4
|
+
import { assetRowSchema, rejectedWriteMessage, resolveRecordWrite } from '@elytracms/core/persistence'
|
|
5
|
+
import type { DataModel } from './functions'
|
|
3
6
|
import { mutation, query } from './functions'
|
|
4
7
|
import { requireCapability } from './guard'
|
|
5
8
|
|
|
@@ -13,21 +16,37 @@ import { requireCapability } from './guard'
|
|
|
13
16
|
/** Convex storage ids arrive as plain strings over the wire; rebrand for the typed API. */
|
|
14
17
|
const asStorageId = (storageKey: string) => storageKey as GenericId<'_storage'>
|
|
15
18
|
|
|
19
|
+
type AssetDoc = DocumentByName<DataModel, 'assets'>
|
|
20
|
+
|
|
21
|
+
/** An optional column on the wire (EC-577): absent keeps, `null` clears, a value sets. */
|
|
22
|
+
const clearable = <T, F extends string>(validator: Validator<T, 'required', F>) =>
|
|
23
|
+
v.optional(v.union(v.null(), validator))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write one asset (EC-577). The ONE write rule (`resolveRecordWrite`, shared with
|
|
27
|
+
* the in-memory adapter) runs here, inside the transaction: a column absent from
|
|
28
|
+
* the args keeps its stored value, `null` clears it, a value sets it. An edit is
|
|
29
|
+
* ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
|
|
30
|
+
* which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
|
|
31
|
+
* asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
|
|
32
|
+
* left absent), or a `validation` `ConvexError` naming what is missing; a partial
|
|
33
|
+
* row is never stored.
|
|
34
|
+
*/
|
|
16
35
|
export const upsert = mutation({
|
|
17
36
|
args: {
|
|
18
37
|
assetId: v.string(),
|
|
19
|
-
filename: v.string(),
|
|
20
|
-
contentType: v.string(),
|
|
21
|
-
byteSize: v.number(),
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
url:
|
|
28
|
-
focalPoint:
|
|
38
|
+
filename: v.optional(v.string()),
|
|
39
|
+
contentType: v.optional(v.string()),
|
|
40
|
+
byteSize: v.optional(v.number()),
|
|
41
|
+
storageKey: v.optional(v.string()),
|
|
42
|
+
width: clearable(v.number()),
|
|
43
|
+
height: clearable(v.number()),
|
|
44
|
+
alt: clearable(v.string()),
|
|
45
|
+
title: clearable(v.string()),
|
|
46
|
+
url: clearable(v.string()),
|
|
47
|
+
focalPoint: clearable(v.object({ x: v.number(), y: v.number() })),
|
|
29
48
|
/** Downscaled editorial preview variant (EC-400) — a second stored blob. */
|
|
30
|
-
thumb:
|
|
49
|
+
thumb: clearable(
|
|
31
50
|
v.object({
|
|
32
51
|
storageKey: v.string(),
|
|
33
52
|
url: v.string(),
|
|
@@ -36,23 +55,38 @@ export const upsert = mutation({
|
|
|
36
55
|
}),
|
|
37
56
|
),
|
|
38
57
|
/** Delivery LQIP blur-placeholder data URI (EC-420) — rides the delivery wire. */
|
|
39
|
-
lqip:
|
|
40
|
-
/** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. */
|
|
58
|
+
lqip: clearable(v.string()),
|
|
59
|
+
/** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. `null` clears it. */
|
|
41
60
|
meta: v.optional(v.any()),
|
|
61
|
+
/**
|
|
62
|
+
* What a CREATE fills in when the write leaves it absent (`assetCreateDefaults`
|
|
63
|
+
* in core). Sent by the adapter because only it knows the project — rows carry
|
|
64
|
+
* none (EC-179). Ignored on an edit.
|
|
65
|
+
*/
|
|
66
|
+
defaults: v.optional(v.object({ storageKey: v.string() })),
|
|
42
67
|
createdAt: v.string(),
|
|
43
68
|
updatedAt: v.string(),
|
|
44
69
|
},
|
|
45
|
-
handler: async (ctx,
|
|
70
|
+
handler: async (ctx, { assetId, defaults, createdAt, updatedAt, ...write }) => {
|
|
46
71
|
await requireCapability(ctx, 'content.write') // EC-150
|
|
47
72
|
const existing = await ctx.db
|
|
48
73
|
.query('assets')
|
|
49
|
-
.withIndex('by_asset', (q) => q.eq('assetId',
|
|
74
|
+
.withIndex('by_asset', (q) => q.eq('assetId', assetId))
|
|
50
75
|
.first()
|
|
76
|
+
const resolved = resolveRecordWrite(assetRowSchema, existing ?? undefined, write, defaults ?? {})
|
|
77
|
+
if (!resolved.ok) {
|
|
78
|
+
throw new ConvexError({
|
|
79
|
+
code: 'validation',
|
|
80
|
+
message: rejectedWriteMessage('asset', assetId, resolved.issues),
|
|
81
|
+
issues: resolved.issues,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
51
84
|
if (existing) {
|
|
52
|
-
await ctx.db.patch(existing._id, { ...
|
|
85
|
+
await ctx.db.patch(existing._id, { ...(resolved.patch as Partial<AssetDoc>), updatedAt })
|
|
53
86
|
return ctx.db.get(existing._id)
|
|
54
87
|
}
|
|
55
|
-
const
|
|
88
|
+
const row: WithoutSystemFields<AssetDoc> = { ...resolved.row, assetId, createdAt, updatedAt }
|
|
89
|
+
const id = await ctx.db.insert('assets', row)
|
|
56
90
|
return ctx.db.get(id)
|
|
57
91
|
},
|
|
58
92
|
})
|
package/convex/cms.ts
CHANGED
|
@@ -153,6 +153,28 @@ export const deleteDocument = mutation({
|
|
|
153
153
|
},
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* EC-576: drop every document and every version snapshot of the deployment — the
|
|
158
|
+
* explicit drop a seeder runs before writing a known world. One transaction, so a
|
|
159
|
+
* seed never observes a half-cleared store. Administration, not a content write:
|
|
160
|
+
* it requires `project.administer`. Each row delete still passes through the EC-286
|
|
161
|
+
* write-chokepoint trigger, which drops the reference-index rows and schedules the
|
|
162
|
+
* host sweep for the published ones — a seeder re-publishes afterwards anyway.
|
|
163
|
+
*
|
|
164
|
+
* Convex bounds one transaction at 8192 written documents (its documented limit);
|
|
165
|
+
* a store past that size fails loudly here rather than clearing half. That is far
|
|
166
|
+
* above any v1 deployment (the largest measured, Konditorei, holds 331 rows across
|
|
167
|
+
* both tables), so it is documented, not paginated.
|
|
168
|
+
*/
|
|
169
|
+
export const clearDocuments = mutation({
|
|
170
|
+
args: {},
|
|
171
|
+
handler: async (ctx) => {
|
|
172
|
+
await requireCapability(ctx, 'project.administer')
|
|
173
|
+
for (const row of await ctx.db.query('cmsDocuments').collect()) await ctx.db.delete(row._id)
|
|
174
|
+
for (const row of await ctx.db.query('documentVersions').collect()) await ctx.db.delete(row._id)
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
156
178
|
/**
|
|
157
179
|
* Document version history (EC-224) — the document analogue of `graphs:*`.
|
|
158
180
|
* Append-only snapshots in `documentVersions`; the live one is pinned by
|
package/convex/delivery.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import { unfoldValue } from '@elytracms/core/cms-core'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
|
|
4
|
+
import type { AssetRecord } from '@elytracms/core/persistence'
|
|
5
5
|
import studioPackage from '../package.json'
|
|
6
6
|
import { query } from './functions'
|
|
7
7
|
import type { QueryCtx } from './functions'
|
|
@@ -37,6 +37,15 @@ import { requireCapability } from './guard'
|
|
|
37
37
|
* (EC-183/EC-207, AD-11) — the host reads them from its own config, not this
|
|
38
38
|
* snapshot. EC-287: there is no project graph — layouts/structure are code.
|
|
39
39
|
*
|
|
40
|
+
* EC-574: the snapshot used to carry a `sources` strand — the data-binding
|
|
41
|
+
* subsystem's persisted `cms-data-sources` rows, projected from EVERY stored row
|
|
42
|
+
* including drafts (the wave-0089 finding: draft rows reached the public
|
|
43
|
+
* snapshot, and nothing revalidated it). The subsystem is deleted, so the strand
|
|
44
|
+
* is gone with it; a deployment that still holds `cms-data-sources` rows serves
|
|
45
|
+
* them like any other document of a collection its config does not declare
|
|
46
|
+
* (unpinned, so never on the public snapshot; on the draft snapshot as a plain
|
|
47
|
+
* document the host's content client reports as `unknown-collection`).
|
|
48
|
+
*
|
|
40
49
|
* `draftSnapshot` is the draft-perspective equivalent (all stored document rows
|
|
41
50
|
* including drafts) and is gated by the deployment env var `DRAFT_PREVIEW_TOKEN`
|
|
42
51
|
* — set via `npx convex env set`. A missing or
|
|
@@ -48,19 +57,6 @@ import { requireCapability } from './guard'
|
|
|
48
57
|
interface DeliverySnapshot {
|
|
49
58
|
projectId: string
|
|
50
59
|
perspective: 'published' | 'draft'
|
|
51
|
-
/**
|
|
52
|
-
* Data-source definitions the published graph's bindings reference (EC-166
|
|
53
|
-
* delivery): the builder persists its provisioned sources (per-section
|
|
54
|
-
* queries, per-template "current document" sources, provider refs) as
|
|
55
|
-
* documents in the `cms-data-sources` system collection; this surfaces them
|
|
56
|
-
* in the structural `CanvasDataSource` shape (`kind`/`id`/`label`/`config`)
|
|
57
|
-
* the host's payload materialization consumes. Sources are config, not
|
|
58
|
-
* content — perspective rules don't apply — but exposure is deliberate:
|
|
59
|
-
* only cms-kind query configs and provider-backed refs are returned (a
|
|
60
|
-
* rest-kind source's headers stay inside the deployment), and the captured
|
|
61
|
-
* sample/schema envelope (builder preview state) is not delivery data.
|
|
62
|
-
*/
|
|
63
|
-
sources: unknown[]
|
|
64
60
|
/** Stored document rows visible in the perspective (published: pinned versions only). */
|
|
65
61
|
documents: unknown[]
|
|
66
62
|
/** Asset metadata in the delivery `AssetRecord` shape (with serve URLs). */
|
|
@@ -75,67 +71,32 @@ interface DeliverySnapshot {
|
|
|
75
71
|
redirects: unknown[]
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
/** One document per persisted source — must match the studio's sources-store. */
|
|
79
|
-
const DATA_SOURCES_COLLECTION = 'cms-data-sources'
|
|
80
|
-
|
|
81
|
-
/** The functionRef namespace marking a convex-kind source as provider-backed. */
|
|
82
|
-
const PROVIDER_FUNCTION_PREFIX = 'provider:'
|
|
83
|
-
|
|
84
74
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
75
|
+
* The delivery-shaped asset records of this deployment (EC-577, D13/F24) — the
|
|
76
|
+
* ONE row → record projection the Convex delivery route reads. (A second route,
|
|
77
|
+
* the EC-145 `createContent` kit, read it too until EC-574 deleted the kit; before
|
|
78
|
+
* EC-577 each route copied the row field by field, and the kit's copy had lost
|
|
79
|
+
* `lqip`, so the same asset blurred up on the snapshot path and not through the
|
|
80
|
+
* kit.) The columns come from the core
|
|
81
|
+
* record schema (`assetRecordFromRow`), and the delivery cut (no editorial `thumb`
|
|
82
|
+
* or library `title`) from `deliveryAssetRecord`.
|
|
90
83
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `materializeSourcePayloads` replays through `listDocuments`;
|
|
94
|
-
* - `convex`-kind sources pass only when provider-backed
|
|
95
|
-
* (`functionRef: provider:<id>`) — a provider ref names project code, it
|
|
96
|
-
* carries no secret;
|
|
97
|
-
* - `rest`-kind sources never leave the deployment (their headers could hold
|
|
98
|
-
* credentials), and the captured sample/schema envelope (builder preview
|
|
99
|
-
* state, not delivery config) is stripped for every kind.
|
|
84
|
+
* `projectId`: rows carry none (single-tenant, EC-179); the host still parses
|
|
85
|
+
* delivery assets with one (the constant slug from the args, EC-179 §4).
|
|
100
86
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* 500-ing the public query for every page of the live site. Both parses here degrade
|
|
106
|
-
* instead: `JSON.parse` is already `try`/`catch`-guarded, and `safeParseTotal`
|
|
107
|
-
* turns the schema-side stack overflow into the ordinary `!success` skip below.
|
|
108
|
-
* (Exported for the EC-304 regression pin.)
|
|
87
|
+
* URL: the stored serve URL when available (EC-151), else a live storage lookup
|
|
88
|
+
* for older rows. Seeded rows carry a placeholder storage key that is not a real
|
|
89
|
+
* storage id — `getUrl` throws on those, so degrade to "no URL" instead of
|
|
90
|
+
* failing the whole read (the host renders its explicit unresolved fallback).
|
|
109
91
|
*/
|
|
110
|
-
export function
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
const values = (typeof row.values === 'object' && row.values !== null ? row.values : {}) as {
|
|
117
|
-
config?: unknown
|
|
118
|
-
}
|
|
119
|
-
if (typeof values.config !== 'string') continue
|
|
120
|
-
let raw: unknown
|
|
121
|
-
try {
|
|
122
|
-
raw = JSON.parse(values.config)
|
|
123
|
-
} catch {
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
const parsed = safeParseTotal(dataSourceSchema, raw)
|
|
127
|
-
if (!parsed.success) continue
|
|
128
|
-
const source = parsed.data
|
|
129
|
-
if (source.kind === 'rest') continue
|
|
130
|
-
if (
|
|
131
|
-
source.kind === 'convex' &&
|
|
132
|
-
!source.config.functionRef.startsWith(PROVIDER_FUNCTION_PREFIX)
|
|
133
|
-
) {
|
|
134
|
-
continue
|
|
135
|
-
}
|
|
136
|
-
sources.push({ kind: source.kind, id: source.id, label: source.label, config: source.config })
|
|
92
|
+
export async function loadDeliveryAssets(ctx: QueryCtx, projectId: string): Promise<AssetRecord[]> {
|
|
93
|
+
const rows = await ctx.db.query('assets').withIndex('by_asset').collect()
|
|
94
|
+
const assets: AssetRecord[] = []
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const url = row.url ?? (await ctx.storage.getUrl(row.storageKey).catch(() => null)) ?? undefined
|
|
97
|
+
assets.push(deliveryAssetRecord(assetRecordFromRow(row, projectId), url))
|
|
137
98
|
}
|
|
138
|
-
return
|
|
99
|
+
return assets
|
|
139
100
|
}
|
|
140
101
|
|
|
141
102
|
export async function loadSnapshot(
|
|
@@ -180,50 +141,7 @@ export async function loadSnapshot(
|
|
|
180
141
|
}
|
|
181
142
|
}
|
|
182
143
|
|
|
183
|
-
const
|
|
184
|
-
const assets: unknown[] = []
|
|
185
|
-
for (const row of assetRows) {
|
|
186
|
-
// Same URL convention as the EC-145 content kit: stored serve URL when
|
|
187
|
-
// available (EC-151), else a live storage lookup for older rows. Seeded
|
|
188
|
-
// rows carry a placeholder storage key that is not a real storage id —
|
|
189
|
-
// `getUrl` throws on those, so degrade to "no URL" instead of failing
|
|
190
|
-
// the whole snapshot (the host renders its explicit unresolved fallback).
|
|
191
|
-
const url = row.url ?? (await ctx.storage.getUrl(row.storageKey).catch(() => null)) ?? undefined
|
|
192
|
-
assets.push({
|
|
193
|
-
id: row.assetId,
|
|
194
|
-
// Wire-compat: the host still parses delivery assets with `projectId`
|
|
195
|
-
// (the constant slug from the snapshot args) until the host drops it (EC-179 §4).
|
|
196
|
-
projectId,
|
|
197
|
-
filename: row.filename,
|
|
198
|
-
contentType: row.contentType,
|
|
199
|
-
byteSize: row.byteSize,
|
|
200
|
-
...(row.width !== undefined ? { width: row.width } : {}),
|
|
201
|
-
...(row.height !== undefined ? { height: row.height } : {}),
|
|
202
|
-
...(row.alt !== undefined ? { alt: row.alt } : {}),
|
|
203
|
-
// EC-301: thread focalPoint (EC-230) + the schema-driven metadata bag into
|
|
204
|
-
// the delivery snapshot — both were omitted here, dropping a studio-set
|
|
205
|
-
// hotspot/metadata on the LIVE path.
|
|
206
|
-
...(row.focalPoint !== undefined ? { focalPoint: row.focalPoint } : {}),
|
|
207
|
-
// EC-420 (AD-2): the LQIP blur placeholder is DELIVERY data, so it ships on
|
|
208
|
-
// the wire (the EC-368 image seam turns it into a next/image blurDataURL).
|
|
209
|
-
// The editorial `thumb` (EC-400) is deliberately NOT projected — it stays
|
|
210
|
-
// in the studio, per the EC-311 additive-metadata invariant.
|
|
211
|
-
...(row.lqip !== undefined ? { lqip: row.lqip } : {}),
|
|
212
|
-
...(row.meta !== undefined ? { meta: row.meta } : {}),
|
|
213
|
-
storageKey: row.storageKey,
|
|
214
|
-
...(url !== undefined ? { url } : {}),
|
|
215
|
-
createdAt: row.createdAt,
|
|
216
|
-
updatedAt: row.updatedAt,
|
|
217
|
-
})
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// LIVE-mode data sources: the builder's persisted source records, projected
|
|
221
|
-
// to the delivery envelope (config only — see the helper's exposure rules).
|
|
222
|
-
// Sources are project *config*, not perspective-gated content, so they are
|
|
223
|
-
// extracted from every stored row (independent of publish state) — published
|
|
224
|
-
// bindings must always resolve their definitions, even when the defining
|
|
225
|
-
// `cms-data-sources` row is not itself "published".
|
|
226
|
-
const sources = dataSourcesFromSystemCollection(documentRows.map(unfoldDraft))
|
|
144
|
+
const assets = await loadDeliveryAssets(ctx, projectId)
|
|
227
145
|
|
|
228
146
|
// Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect
|
|
229
147
|
// is stored but never live, in either perspective. Projected to the routing
|
|
@@ -236,7 +154,6 @@ export async function loadSnapshot(
|
|
|
236
154
|
return {
|
|
237
155
|
projectId,
|
|
238
156
|
perspective,
|
|
239
|
-
sources,
|
|
240
157
|
documents,
|
|
241
158
|
assets,
|
|
242
159
|
redirects,
|
package/convex/functions.ts
CHANGED
|
@@ -39,9 +39,9 @@ export const action = actionGeneric
|
|
|
39
39
|
/**
|
|
40
40
|
* EC-286: every mutation runs through the write-chokepoint triggers (`triggers.ts`)
|
|
41
41
|
* via `customCtx(triggers.wrapDB)`, so reference-index maintenance and host
|
|
42
|
-
* revalidation derive by construction on
|
|
43
|
-
* calls a derivation helper by hand.
|
|
44
|
-
*
|
|
42
|
+
* revalidation derive by construction on every write to a registered table — no
|
|
43
|
+
* mutation calls a derivation helper by hand. `triggers.ts` is the one place that
|
|
44
|
+
* says which tables those are.
|
|
45
45
|
*/
|
|
46
46
|
export const mutation = customMutation(
|
|
47
47
|
mutationGeneric as MutationBuilder<DataModel, 'public'>,
|