@elytracms/studio 0.0.88 → 0.0.90
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 +35 -42
- package/convex/delivery.ts +8 -11
- package/convex/document-revalidate.test.ts +175 -0
- package/convex/redirect-revalidate.test.ts +36 -36
- package/convex/revalidate.ts +18 -30
- package/convex/triggers.ts +47 -38
- package/convex/webhooks.ts +77 -62
- package/dist/components/account/self-account-view-source.js +2 -1
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +1 -1
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/app-views/default-modules.js +3 -3
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/assets/asset-grid.js +1 -1
- package/dist/components/assets/asset-grid.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.js +3 -2
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/reuse-context.js +2 -2
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +6 -2
- package/dist/components/builder/reuse.js +12 -7
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +0 -6
- package/dist/components/cms/cell-edit.js +5 -23
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +23 -24
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +14 -4
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +20 -21
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/duplicate-document.d.ts +3 -2
- package/dist/components/cms/duplicate-document.js +5 -5
- package/dist/components/cms/duplicate-document.js.map +1 -1
- package/dist/components/cms/field-control.js +7 -6
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/publish-clean.d.ts +13 -6
- package/dist/components/cms/publish-clean.js +14 -6
- package/dist/components/cms/publish-clean.js.map +1 -1
- package/dist/components/cms/use-publish-controller.js +3 -2
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +1 -0
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +10 -4
- package/dist/components/components/transfer.js +11 -9
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +3 -2
- package/dist/components/delivery-health-banner.js +3 -2
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.js +3 -2
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/users/users-collection-view-source.js +2 -1
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/lib/ai/apply-content.js +32 -28
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/translate-document.js +11 -7
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +9 -5
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.js +10 -29
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/vite.d.ts +22 -2
- package/dist/lib/ai/vite.js +12 -3
- package/dist/lib/ai/vite.js.map +1 -1
- package/dist/lib/cms/published-documents.d.ts +14 -3
- package/dist/lib/cms/published-documents.js +12 -3
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +4 -4
- package/dist/lib/cms-views/view-model.js +16 -13
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/data/collection-content.js +36 -14
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/delivery/instant-publish.d.ts +0 -3
- package/dist/lib/delivery/instant-publish.js +0 -1
- package/dist/lib/delivery/instant-publish.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/locale/fallback.d.ts +5 -19
- package/dist/lib/locale/fallback.js +18 -39
- package/dist/lib/locale/fallback.js.map +1 -1
- package/dist/lib/locale/locale-store.js +2 -1
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +4 -6
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/backend-mode.d.ts +5 -25
- package/dist/lib/persistence/backend-mode.js +13 -30
- package/dist/lib/persistence/backend-mode.js.map +1 -1
- package/dist/lib/references/reference-model.d.ts +17 -3
- package/dist/lib/references/reference-model.js +17 -23
- package/dist/lib/references/reference-model.js.map +1 -1
- package/dist/lib/references/references-store.js +7 -0
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +0 -7
- package/dist/lib/workspace/index.js +0 -9
- package/dist/lib/workspace/index.js.map +1 -1
- package/package.json +2 -6
- package/dist/lib/webhooks/config.d.ts +0 -15
- package/dist/lib/webhooks/config.js +0 -65
- package/dist/lib/webhooks/config.js.map +0 -1
- package/dist/lib/webhooks/dispatcher.d.ts +0 -70
- package/dist/lib/webhooks/dispatcher.js +0 -61
- package/dist/lib/webhooks/dispatcher.js.map +0 -1
- package/dist/lib/webhooks/index.d.ts +0 -11
- package/dist/lib/webhooks/index.js +0 -10
- package/dist/lib/webhooks/index.js.map +0 -1
- package/dist/lib/webhooks/signing.d.ts +0 -15
- package/dist/lib/webhooks/signing.js +0 -24
- package/dist/lib/webhooks/signing.js.map +0 -1
- package/dist/lib/webhooks/tags.d.ts +0 -25
- package/dist/lib/webhooks/tags.js +0 -289
- package/dist/lib/webhooks/tags.js.map +0 -1
|
@@ -4,7 +4,9 @@ import { makeFunctionReference } from 'convex/server'
|
|
|
4
4
|
import schema from './schema'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* EC-579
|
|
7
|
+
* EC-579 — an asset edit must reach the live site (the wave-0089 carve-out; since
|
|
8
|
+
* the ruling the trigger sends the typed EVENT `{ kind: 'asset', id }`, and the host
|
|
9
|
+
* receiver answers it with `asset:<id>` + the project sweep).
|
|
8
10
|
*
|
|
9
11
|
* An asset row rides the delivery wire field by field (`delivery.ts`: url, alt,
|
|
10
12
|
* dimensions, focal point, lqip, the `meta` bag), so editing one changes
|
|
@@ -31,8 +33,7 @@ const removeAsset = makeFunctionReference<'mutation'>('assets:remove')
|
|
|
31
33
|
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
32
34
|
|
|
33
35
|
const SESSION_USER_ID = 'user_admin'
|
|
34
|
-
const
|
|
35
|
-
const SWEEP = [`p:${SCOPE}:project`]
|
|
36
|
+
const ASSET_EVENT = { kind: 'asset', id: 'asset_ec579_1' }
|
|
36
37
|
|
|
37
38
|
async function freshBackend() {
|
|
38
39
|
const t = convexTest(schema, modules)
|
|
@@ -48,33 +49,33 @@ async function freshBackend() {
|
|
|
48
49
|
return t
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/** The scheduled `webhooks:deliver` calls, in order
|
|
52
|
-
async function scheduledRevalidations(
|
|
53
|
-
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
54
|
-
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
52
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
53
|
+
async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
|
|
55
54
|
return t.run(async (ctx) => {
|
|
56
55
|
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
57
56
|
return rows
|
|
58
57
|
.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
|
-
})
|
|
58
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
63
59
|
})
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
/** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
|
|
63
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
64
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
65
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
66
|
+
const previous = keys.map((key) => env[key])
|
|
67
|
+
if (configured) {
|
|
68
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
69
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
70
|
+
} else {
|
|
71
|
+
for (const key of keys) delete env[key]
|
|
73
72
|
}
|
|
74
73
|
return run().finally(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
keys.forEach((key, index) => {
|
|
75
|
+
const value = previous[index]
|
|
76
|
+
if (value === undefined) delete env[key]
|
|
77
|
+
else env[key] = value
|
|
78
|
+
})
|
|
78
79
|
})
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -94,7 +95,7 @@ const assetArgs = {
|
|
|
94
95
|
|
|
95
96
|
describe('EC-579 — the write chokepoint revalidates on every asset edit', () => {
|
|
96
97
|
it('an alt edit schedules a project sweep; the create before it schedules nothing', async () => {
|
|
97
|
-
await
|
|
98
|
+
await withRevalidationEnv(true, async () => {
|
|
98
99
|
const t = await freshBackend()
|
|
99
100
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
100
101
|
|
|
@@ -110,28 +111,24 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
110
111
|
})
|
|
111
112
|
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
112
113
|
|
|
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`).
|
|
114
|
+
// A brand-new asset is on no page yet, so its create invalidates nothing.
|
|
115
115
|
await asAdmin.mutation(upsertAsset, assetArgs)
|
|
116
116
|
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
117
117
|
|
|
118
118
|
await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
|
|
119
119
|
|
|
120
120
|
// The edit IS live output: the published snapshot already serves it.
|
|
121
|
-
const snapshot = (await t.query(deliverySnapshot, { projectId:
|
|
121
|
+
const snapshot = (await t.query(deliverySnapshot, { projectId: 'ec579' })) as {
|
|
122
122
|
assets: { alt?: string }[]
|
|
123
123
|
}
|
|
124
124
|
expect(snapshot.assets[0]?.alt).toBe('new')
|
|
125
125
|
|
|
126
|
-
expect(await scheduledRevalidations(t)).toEqual([
|
|
127
|
-
{ event: 'redirects.save', tags: SWEEP },
|
|
128
|
-
{ event: 'assets.upsert', tags: SWEEP },
|
|
129
|
-
])
|
|
126
|
+
expect(await scheduledRevalidations(t)).toEqual([{ kind: 'redirects' }, ASSET_EVENT])
|
|
130
127
|
})
|
|
131
128
|
})
|
|
132
129
|
|
|
133
130
|
it('schedules a sweep on a focal-point edit, a meta edit, a re-upload, and a delete', async () => {
|
|
134
|
-
await
|
|
131
|
+
await withRevalidationEnv(true, async () => {
|
|
135
132
|
const t = await freshBackend()
|
|
136
133
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
137
134
|
|
|
@@ -147,20 +144,16 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
147
144
|
})
|
|
148
145
|
await asAdmin.mutation(removeAsset, { assetId: assetArgs.assetId })
|
|
149
146
|
|
|
150
|
-
//
|
|
151
|
-
// asset
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{ event: 'assets.upsert', tags: SWEEP },
|
|
156
|
-
{ event: 'assets.upsert', tags: SWEEP },
|
|
157
|
-
{ event: 'assets.remove', tags: SWEEP },
|
|
158
|
-
])
|
|
147
|
+
// One event per edit and per delete — the same event for both: the receiver
|
|
148
|
+
// derives `asset:<id>` + the sweep either way (a page reading the asset through
|
|
149
|
+
// a document asset FIELD carries no `asset:` tag — `next/src/route-core.ts`
|
|
150
|
+
// `scopeAssetsToPage`, EC-589).
|
|
151
|
+
expect(await scheduledRevalidations(t)).toEqual([ASSET_EVENT, ASSET_EVENT, ASSET_EVENT, ASSET_EVENT])
|
|
159
152
|
})
|
|
160
153
|
})
|
|
161
154
|
|
|
162
155
|
it('removing an asset that does not exist writes nothing and schedules nothing', async () => {
|
|
163
|
-
await
|
|
156
|
+
await withRevalidationEnv(true, async () => {
|
|
164
157
|
const t = await freshBackend()
|
|
165
158
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
166
159
|
await asAdmin.mutation(removeAsset, { assetId: 'asset_never_existed' })
|
|
@@ -168,8 +161,8 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
168
161
|
})
|
|
169
162
|
})
|
|
170
163
|
|
|
171
|
-
it('stays a silent no-op on a deployment
|
|
172
|
-
await
|
|
164
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
|
|
165
|
+
await withRevalidationEnv(false, async () => {
|
|
173
166
|
const t = await freshBackend()
|
|
174
167
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
175
168
|
await asAdmin.mutation(upsertAsset, assetArgs)
|
package/convex/delivery.ts
CHANGED
|
@@ -211,19 +211,17 @@ export const draftSnapshot = query({
|
|
|
211
211
|
/**
|
|
212
212
|
* EC-268: instant-publish configuration health for the studio UI. Returns ONLY
|
|
213
213
|
* whether each delivery env var is set — never the values — so the studio can warn
|
|
214
|
-
* when a publish would silently no-op. The pipeline gates on
|
|
215
|
-
* schedules the webhook
|
|
216
|
-
*
|
|
217
|
-
* `
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
214
|
+
* when a publish would silently no-op. The pipeline gates on exactly this pair: the
|
|
215
|
+
* write-chokepoint trigger schedules the webhook, and the `deliver` action sends it,
|
|
216
|
+
* only when `CANVAS_REVALIDATE_ENDPOINT` + `CANVAS_REVALIDATE_SECRET` are both set
|
|
217
|
+
* (`webhooks.ts` `revalidationTarget`). EC-579: there is no third, scope var — the
|
|
218
|
+
* host derives its tags with its own scope. Gated to deployment members (EC-304) —
|
|
219
|
+
* config state is not public, and an authenticated non-member (open Password
|
|
220
|
+
* sign-up) is not a member.
|
|
221
221
|
*/
|
|
222
222
|
export const deliveryConfig = query({
|
|
223
223
|
args: {},
|
|
224
|
-
handler: async (
|
|
225
|
-
ctx,
|
|
226
|
-
): Promise<{ projectScope: boolean; revalidateEndpoint: boolean; revalidateSecret: boolean }> => {
|
|
224
|
+
handler: async (ctx): Promise<{ revalidateEndpoint: boolean; revalidateSecret: boolean }> => {
|
|
227
225
|
await requireCapability(ctx, 'content.read')
|
|
228
226
|
const env = process.env as Record<string, string | undefined>
|
|
229
227
|
const isSet = (key: string): boolean => {
|
|
@@ -231,7 +229,6 @@ export const deliveryConfig = query({
|
|
|
231
229
|
return typeof value === 'string' && value.trim() !== ''
|
|
232
230
|
}
|
|
233
231
|
return {
|
|
234
|
-
projectScope: isSet('CANVAS_PROJECT_SCOPE'),
|
|
235
232
|
revalidateEndpoint: isSet('CANVAS_REVALIDATE_ENDPOINT'),
|
|
236
233
|
revalidateSecret: isSet('CANVAS_REVALIDATE_SECRET'),
|
|
237
234
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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 — the write chokepoint is the ONE revalidation emitter, and it sends the
|
|
8
|
+
* EVENT, not tags.
|
|
9
|
+
*
|
|
10
|
+
* The REAL `cms:*` mutations through the REAL `functions.ts` trigger wrapper on
|
|
11
|
+
* `convex-test`'s in-memory backend, asserting on the `_scheduled_functions` system
|
|
12
|
+
* table: what `scheduleRevalidate` hands the `webhooks:deliver` action. The host
|
|
13
|
+
* receiver derives the tags from the event with its own scope and locales
|
|
14
|
+
* (`@elytracms/next` `revalidationTags`), so nothing here knows a tag format or a
|
|
15
|
+
* scope — the event carries only what changed.
|
|
16
|
+
*
|
|
17
|
+
* Documents reach the live wire only through their published pin (`delivery.ts`),
|
|
18
|
+
* so only a pin move or the delete of a pinned row is an event; a draft save, a
|
|
19
|
+
* version snapshot and the delete of a never-published row are not.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const modules = {
|
|
23
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
24
|
+
'./_generated/api.ts': async () => ({}),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const upsertDocument = makeFunctionReference<'mutation'>('cms:upsertDocument')
|
|
28
|
+
const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
|
|
29
|
+
const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
|
|
30
|
+
const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
|
|
31
|
+
const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
|
|
32
|
+
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
33
|
+
|
|
34
|
+
const SESSION_USER_ID = 'user_admin'
|
|
35
|
+
const STAMP = '2026-10-25T00:00:00.000Z'
|
|
36
|
+
|
|
37
|
+
async function freshBackend() {
|
|
38
|
+
const t = convexTest(schema, modules)
|
|
39
|
+
await t.run(async (ctx) => {
|
|
40
|
+
await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: STAMP, updatedAt: STAMP })
|
|
41
|
+
})
|
|
42
|
+
return { t, asAdmin: t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' }) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Backend = Awaited<ReturnType<typeof freshBackend>>
|
|
46
|
+
|
|
47
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
48
|
+
async function scheduledEvents({ t }: Backend): Promise<unknown[]> {
|
|
49
|
+
return t.run(async (ctx) => {
|
|
50
|
+
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
51
|
+
return rows
|
|
52
|
+
.filter((row) => row.name.startsWith('webhooks'))
|
|
53
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Convex gate: the endpoint + secret pair the `deliver` action posts with. */
|
|
58
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
59
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
60
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
61
|
+
const previous = keys.map((key) => env[key])
|
|
62
|
+
if (configured) {
|
|
63
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
64
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
65
|
+
} else {
|
|
66
|
+
for (const key of keys) delete env[key]
|
|
67
|
+
}
|
|
68
|
+
return run().finally(() => {
|
|
69
|
+
keys.forEach((key, index) => {
|
|
70
|
+
const value = previous[index]
|
|
71
|
+
if (value === undefined) delete env[key]
|
|
72
|
+
else env[key] = value
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function doc(collection: string, id: string, values: Record<string, unknown>) {
|
|
78
|
+
return { collection, id, values }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function saveDraft({ asAdmin }: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
82
|
+
await asAdmin.mutation(upsertDocument, {
|
|
83
|
+
collection,
|
|
84
|
+
docId: id,
|
|
85
|
+
document: doc(collection, id, values),
|
|
86
|
+
createdAt: STAMP,
|
|
87
|
+
updatedAt: STAMP,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
92
|
+
const version = (await backend.asAdmin.mutation(saveVersion, {
|
|
93
|
+
collection,
|
|
94
|
+
docId: id,
|
|
95
|
+
snapshot: doc(collection, id, values),
|
|
96
|
+
createdAt: STAMP,
|
|
97
|
+
})) as { version: number }
|
|
98
|
+
await backend.asAdmin.mutation(publishVersion, {
|
|
99
|
+
collection,
|
|
100
|
+
docId: id,
|
|
101
|
+
version: version.version,
|
|
102
|
+
updatedAt: STAMP,
|
|
103
|
+
publishedAt: STAMP,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('EC-579 — documents: one event per change to the live pin', () => {
|
|
108
|
+
it('publish, republish, unpublish and delete each schedule exactly one event with collection + id', async () => {
|
|
109
|
+
await withRevalidationEnv(true, async () => {
|
|
110
|
+
const backend = await freshBackend()
|
|
111
|
+
await saveDraft(backend, 'post', 'hello', { title: 'Draft' })
|
|
112
|
+
// A draft save is not live output: nothing scheduled.
|
|
113
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
114
|
+
|
|
115
|
+
await publish(backend, 'post', 'hello', { title: 'v1' })
|
|
116
|
+
await publish(backend, 'post', 'hello', { title: 'v2' })
|
|
117
|
+
await backend.asAdmin.mutation(unpublishDocument, { collection: 'post', docId: 'hello', updatedAt: STAMP })
|
|
118
|
+
await publish(backend, 'post', 'hello', { title: 'v3' })
|
|
119
|
+
await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'hello' })
|
|
120
|
+
|
|
121
|
+
expect(await scheduledEvents(backend)).toEqual([
|
|
122
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
123
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
124
|
+
{ kind: 'unpublish', collection: 'post', id: 'hello' },
|
|
125
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
126
|
+
{ kind: 'delete', collection: 'post', id: 'hello' },
|
|
127
|
+
])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('a draft save on a PUBLISHED document, and the delete of a never-published one, schedule nothing', async () => {
|
|
132
|
+
await withRevalidationEnv(true, async () => {
|
|
133
|
+
const backend = await freshBackend()
|
|
134
|
+
await saveDraft(backend, 'post', 'live', { title: 'v1' })
|
|
135
|
+
await publish(backend, 'post', 'live', { title: 'v1' })
|
|
136
|
+
// Known-positive control: this instrument sees the publish.
|
|
137
|
+
expect(await scheduledEvents(backend)).toHaveLength(1)
|
|
138
|
+
|
|
139
|
+
await saveDraft(backend, 'post', 'live', { title: 'edited draft' })
|
|
140
|
+
await saveDraft(backend, 'post', 'never', { title: 'draft only' })
|
|
141
|
+
await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'never' })
|
|
142
|
+
expect(await scheduledEvents(backend)).toHaveLength(1)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The wave-0089 finding (EC-579 Done § Found-but-left 4): a draft write to a
|
|
148
|
+
* `cms-data-sources` record changed the public snapshot and no revalidation fired.
|
|
149
|
+
* EC-574 deleted the snapshot's `sources` strand; this pins that it stays closed —
|
|
150
|
+
* the draft row is not on the published wire, so silence is correct.
|
|
151
|
+
*/
|
|
152
|
+
it('a cms-data-sources draft write changes no published output and schedules nothing', async () => {
|
|
153
|
+
await withRevalidationEnv(true, async () => {
|
|
154
|
+
const backend = await freshBackend()
|
|
155
|
+
const before = await backend.t.query(deliverySnapshot, { projectId: 'p' })
|
|
156
|
+
await saveDraft(backend, 'cms-data-sources', 'source-1', { title: 'A data source' })
|
|
157
|
+
const after = await backend.t.query(deliverySnapshot, { projectId: 'p' })
|
|
158
|
+
expect(after).toEqual(before)
|
|
159
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair — the write still lands', async () => {
|
|
164
|
+
await withRevalidationEnv(false, async () => {
|
|
165
|
+
const backend = await freshBackend()
|
|
166
|
+
await saveDraft(backend, 'post', 'hello', { title: 'v1' })
|
|
167
|
+
await publish(backend, 'post', 'hello', { title: 'v1' })
|
|
168
|
+
// The first test is the known-positive control for the same publish.
|
|
169
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
170
|
+
expect(
|
|
171
|
+
await backend.t.run(async (ctx) => (await ctx.db.query('cmsDocuments').first())?.publishedVersion),
|
|
172
|
+
).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -4,12 +4,12 @@ import { makeFunctionReference } from 'convex/server'
|
|
|
4
4
|
import schema from './schema'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* EC-510 —
|
|
7
|
+
* EC-510 — "a redirect change must reach the live site".
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The EC-286 write chokepoint is the ONE emitter (EC-579 deleted the studio's
|
|
10
|
+
* browser dispatcher), and it catches writers the studio does not own. Before
|
|
11
|
+
* EC-510 a redirect write reached the host by NO path, while a document publish
|
|
12
|
+
* reached it by two.
|
|
13
13
|
*
|
|
14
14
|
* This runs the REAL `redirects.save`/`redirects.remove` mutations through the
|
|
15
15
|
* REAL `functions.ts` trigger wrapper on `convex-test`'s in-memory backend, and
|
|
@@ -17,10 +17,10 @@ import schema from './schema'
|
|
|
17
17
|
* `scheduleRevalidate` actually produces (a mutation cannot `fetch`, so it hands
|
|
18
18
|
* the signed POST to the `webhooks:deliver` action).
|
|
19
19
|
*
|
|
20
|
-
* The
|
|
21
|
-
* deployment must stay a silent no-op, because publishing may never
|
|
22
|
-
* reachable host. Both sides are pinned here so neither can regress
|
|
23
|
-
* other.
|
|
20
|
+
* The endpoint/secret pair is the gate `scheduleRevalidate` reads: an
|
|
21
|
+
* unconfigured deployment must stay a silent no-op, because publishing may never
|
|
22
|
+
* depend on a reachable host. Both sides are pinned here so neither can regress
|
|
23
|
+
* into the other.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
const modules = {
|
|
@@ -34,7 +34,6 @@ const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
|
|
|
34
34
|
const removeRedirect = makeFunctionReference<'mutation'>('redirects:remove')
|
|
35
35
|
|
|
36
36
|
const SESSION_USER_ID = 'user_admin'
|
|
37
|
-
const SCOPE = 'ec510-scope'
|
|
38
37
|
|
|
39
38
|
async function freshBackend() {
|
|
40
39
|
const t = convexTest(schema, modules)
|
|
@@ -50,33 +49,33 @@ async function freshBackend() {
|
|
|
50
49
|
return t
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
/** The scheduled `webhooks:deliver` calls, in order
|
|
54
|
-
async function scheduledRevalidations(
|
|
55
|
-
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
56
|
-
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
52
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
53
|
+
async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
|
|
57
54
|
return t.run(async (ctx) => {
|
|
58
55
|
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
59
56
|
return rows
|
|
60
57
|
.filter((row) => row.name.startsWith('webhooks'))
|
|
61
|
-
.map((row) => {
|
|
62
|
-
const [args] = row.args as [{ event?: unknown; tags?: unknown }]
|
|
63
|
-
return { event: args?.event, tags: args?.tags }
|
|
64
|
-
})
|
|
58
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
65
59
|
})
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
/** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
|
|
63
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
64
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
65
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
66
|
+
const previous = keys.map((key) => env[key])
|
|
67
|
+
if (configured) {
|
|
68
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
69
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
70
|
+
} else {
|
|
71
|
+
for (const key of keys) delete env[key]
|
|
75
72
|
}
|
|
76
73
|
return run().finally(() => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
keys.forEach((key, index) => {
|
|
75
|
+
const value = previous[index]
|
|
76
|
+
if (value === undefined) delete env[key]
|
|
77
|
+
else env[key] = value
|
|
78
|
+
})
|
|
80
79
|
})
|
|
81
80
|
}
|
|
82
81
|
|
|
@@ -91,8 +90,8 @@ const redirectArgs = {
|
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
describe('EC-510 — the write chokepoint revalidates on every redirect write', () => {
|
|
94
|
-
it('schedules
|
|
95
|
-
await
|
|
93
|
+
it('schedules one `redirects` event on create, retarget, disable, and delete', async () => {
|
|
94
|
+
await withRevalidationEnv(true, async () => {
|
|
96
95
|
const t = await freshBackend()
|
|
97
96
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
98
97
|
|
|
@@ -103,17 +102,18 @@ describe('EC-510 — the write chokepoint revalidates on every redirect write',
|
|
|
103
102
|
await asAdmin.mutation(saveRedirect, { ...redirectArgs, enabled: false })
|
|
104
103
|
await asAdmin.mutation(removeRedirect, { redirectId: redirectArgs.redirectId })
|
|
105
104
|
|
|
105
|
+
// The receiver answers each with the project sweep (`revalidationTags`).
|
|
106
106
|
expect(await scheduledRevalidations(t)).toEqual([
|
|
107
|
-
{
|
|
108
|
-
{
|
|
109
|
-
{
|
|
110
|
-
{
|
|
107
|
+
{ kind: 'redirects' },
|
|
108
|
+
{ kind: 'redirects' },
|
|
109
|
+
{ kind: 'redirects' },
|
|
110
|
+
{ kind: 'redirects' },
|
|
111
111
|
])
|
|
112
112
|
})
|
|
113
113
|
})
|
|
114
114
|
|
|
115
|
-
it('stays a silent no-op on a deployment
|
|
116
|
-
await
|
|
115
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
|
|
116
|
+
await withRevalidationEnv(false, async () => {
|
|
117
117
|
const t = await freshBackend()
|
|
118
118
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
119
119
|
await asAdmin.mutation(saveRedirect, redirectArgs)
|
package/convex/revalidate.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { makeFunctionReference } from 'convex/server'
|
|
2
|
+
import type { RevalidationEvent } from '@elytracms/core/revalidation'
|
|
2
3
|
import type { MutationCtx } from './functions'
|
|
4
|
+
import { revalidationTarget } from './webhooks'
|
|
3
5
|
|
|
4
6
|
// EC-223: the studio's convex functions address each other by STRING ref
|
|
5
7
|
// (resolved against the consumer's deployment), never the per-deployment
|
|
@@ -7,40 +9,26 @@ import type { MutationCtx } from './functions'
|
|
|
7
9
|
const webhooksDeliver = makeFunctionReference<'action'>('webhooks:deliver')
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
* EC-146 / EC-180 — schedule the host revalidation webhook
|
|
11
|
-
*
|
|
12
|
-
* internal action (it signs + POSTs server-side; the secret never
|
|
12
|
+
* EC-146 / EC-180 / EC-579 — schedule the host revalidation webhook for one change to
|
|
13
|
+
* the live wire. A mutation cannot `fetch`, so it hands the typed event to the
|
|
14
|
+
* `webhooks.deliver` internal action (it signs + POSTs server-side; the secret never
|
|
15
|
+
* leaves Convex).
|
|
13
16
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The event says WHAT changed (`{ kind, collection?, id? }`); it carries no tags and no
|
|
18
|
+
* scope. The host receiver derives the tags with its own scope and locales
|
|
19
|
+
* (`@elytracms/next` `revalidationTags`), which is the one place that knows how its
|
|
20
|
+
* cache entries are tagged — including, until EC-515 lands, the project sweep it adds
|
|
21
|
+
* to every event that changes live output.
|
|
22
|
+
*
|
|
23
|
+
* Gate: the endpoint + secret pair (`revalidationTarget`, the one reader of it). An
|
|
24
|
+
* unconfigured deployment (local dev, no env) is a silent no-op — publishing must never
|
|
25
|
+
* depend on a reachable host.
|
|
21
26
|
*
|
|
22
27
|
* Called from the EC-286 write-chokepoint triggers only, so every writer of a
|
|
23
28
|
* live-wire table is covered by construction. Which tables, and which of their
|
|
24
29
|
* writes, is declared once — in `triggers.ts`, not here.
|
|
25
|
-
*
|
|
26
|
-
* Doc/collection-scoped tags (matching the client dispatcher's
|
|
27
|
-
* `webhookTagsForEvent`) would be more surgical for DOCUMENTS, but need the
|
|
28
|
-
* project locales — not available in the Convex env here. The sweep is the
|
|
29
|
-
* honest, reliable v1; threading scoped tags is a precision follow-up. For
|
|
30
|
-
* redirects the sweep is not a compromise but the correct answer — see
|
|
31
|
-
* `ROUTING_OPERATIONS` in `src/lib/webhooks/tags.ts`. For assets it is the only
|
|
32
|
-
* correct answer today: a page reading an asset through a document asset field
|
|
33
|
-
* carries no `asset:<id>` tag (EC-566 D16), so the per-asset tag the browser
|
|
34
|
-
* dispatcher emits would miss it.
|
|
35
30
|
*/
|
|
36
|
-
export async function scheduleRevalidate(ctx: MutationCtx, event:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const scope = env['CANVAS_PROJECT_SCOPE']
|
|
40
|
-
if (!scope) return
|
|
41
|
-
await ctx.scheduler.runAfter(0, webhooksDeliver, {
|
|
42
|
-
projectId: scope,
|
|
43
|
-
event,
|
|
44
|
-
tags: [`p:${scope}:project`],
|
|
45
|
-
})
|
|
31
|
+
export async function scheduleRevalidate(ctx: MutationCtx, event: RevalidationEvent): Promise<void> {
|
|
32
|
+
if (!revalidationTarget()) return
|
|
33
|
+
await ctx.scheduler.runAfter(0, webhooksDeliver, { event })
|
|
46
34
|
}
|