@elytracms/studio 0.0.88 → 0.0.91
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 +49 -43
- package/convex/cms.ts +3 -4
- package/convex/delivery.ts +8 -11
- package/convex/document-revalidate.test.ts +188 -0
- package/convex/redirect-revalidate.test.ts +50 -37
- 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/asset-selection.d.ts +8 -9
- package/dist/components/assets/asset-selection.js +26 -16
- package/dist/components/assets/asset-selection.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/blocks-model.d.ts +7 -5
- package/dist/components/builder/blocks-model.js +11 -9
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.js +1 -1
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +11 -3
- package/dist/components/builder/builder-store.js +12 -5
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +7 -1
- package/dist/components/builder/canvas-dnd.js +6 -3
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +13 -1
- package/dist/components/builder/canvas-render-context.js +28 -18
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas.js +1 -1
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +0 -6
- package/dist/components/builder/clipboard-actions.js +9 -4
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +22 -0
- package/dist/components/builder/composition-field-editor.js +47 -8
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/inline-edit.d.ts +7 -4
- package/dist/components/builder/inline-edit.js +11 -8
- package/dist/components/builder/inline-edit.js.map +1 -1
- package/dist/components/builder/insert-context.js +9 -5
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/layout-inspector.d.ts +2 -2
- package/dist/components/builder/layout-inspector.js +8 -6
- package/dist/components/builder/layout-inspector.js.map +1 -1
- package/dist/components/builder/node-factory.d.ts +31 -14
- package/dist/components/builder/node-factory.js +73 -93
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +3 -1
- package/dist/components/builder/page-tree.js +6 -4
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/property-inspector.js +2 -1
- package/dist/components/builder/property-inspector.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 +7 -3
- package/dist/components/builder/reuse.js +19 -11
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/builder/selection-overlay.d.ts +2 -2
- package/dist/components/builder/selection-overlay.js +3 -3
- package/dist/components/builder/selection-overlay.js.map +1 -1
- package/dist/components/builder/spacing-handles.d.ts +2 -2
- package/dist/components/builder/spacing-handles.js +3 -3
- package/dist/components/builder/spacing-handles.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +1 -1
- package/dist/components/cms/asset-field.js +15 -13
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.js +3 -4
- package/dist/components/cms/asset-picker-core.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 +28 -24
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/discard-draft.d.ts +1 -2
- package/dist/components/cms/discard-draft.js +1 -1
- package/dist/components/cms/discard-draft.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/editable-cell.js +11 -7
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control-lib.d.ts +10 -0
- package/dist/components/cms/field-control-lib.js +20 -0
- package/dist/components/cms/field-control-lib.js.map +1 -1
- package/dist/components/cms/field-control.js +10 -9
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/object-array-field.js +3 -5
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/prop-fields.d.ts +16 -3
- package/dist/components/cms/prop-fields.js +57 -12
- package/dist/components/cms/prop-fields.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/rich-text/embed-props.d.ts +6 -1
- package/dist/components/cms/rich-text/embed-props.js +7 -12
- package/dist/components/cms/rich-text/embed-props.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +13 -6
- package/dist/components/cms/rich-text/insert.js +16 -17
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/use-cell-edit.js +7 -12
- package/dist/components/cms/use-cell-edit.js.map +1 -1
- package/dist/components/cms/use-discard-controller.d.ts +1 -2
- package/dist/components/cms/use-discard-controller.js +1 -2
- package/dist/components/cms/use-discard-controller.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/cms/versions-pane.js +2 -2
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/components/component-detail.d.ts +1 -1
- package/dist/components/components/component-detail.js +2 -4
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.js +9 -4
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/components-index.d.ts +2 -2
- package/dist/components/components/components-index.js +3 -6
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/components/field-inspector.js +5 -2
- package/dist/components/components/field-inspector.js.map +1 -1
- package/dist/components/components/registry-browser.d.ts +6 -5
- package/dist/components/components/registry-browser.js +11 -37
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.d.ts +14 -21
- package/dist/components/components/taxonomy.js +5 -29
- package/dist/components/components/taxonomy.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 +20 -12
- package/dist/components/components/transfer.js +27 -21
- 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/constraints.d.ts +16 -14
- package/dist/lib/ai/constraints.js +64 -48
- package/dist/lib/ai/constraints.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 +15 -32
- 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/assets/asset-placements.d.ts +3 -10
- package/dist/lib/assets/asset-placements.js +17 -8
- package/dist/lib/assets/asset-placements.js.map +1 -1
- package/dist/lib/clipboard/paste-target.js +3 -3
- package/dist/lib/clipboard/paste-target.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 +26 -20
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +10 -7
- package/dist/lib/data/collection-content.js +70 -29
- 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/dnd/drop-rules.d.ts +22 -7
- package/dist/lib/dnd/drop-rules.js +21 -14
- package/dist/lib/dnd/drop-rules.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/i18n/messages.de.js +25 -9
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +21 -7
- package/dist/lib/i18n/messages.en.js +28 -9
- package/dist/lib/i18n/messages.en.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/cms-persistence.js +7 -4
- package/dist/lib/operations/cms-persistence.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/persona/labels.d.ts +4 -9
- package/dist/lib/persona/labels.js +5 -12
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/references/reference-model.d.ts +17 -5
- package/dist/lib/references/reference-model.js +20 -28
- 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/registry/__fixtures__/demo-components.d.ts +32 -0
- package/dist/lib/registry/{project-components.js → __fixtures__/demo-components.js} +18 -5
- package/dist/lib/registry/__fixtures__/demo-components.js.map +1 -0
- package/dist/lib/registry/index.d.ts +9 -13
- package/dist/lib/registry/index.js +34 -61
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/preview.d.ts +22 -7
- package/dist/lib/registry/preview.js +26 -89
- package/dist/lib/registry/preview.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/dist/previews/enumerate.d.ts +23 -27
- package/dist/previews/enumerate.js +27 -48
- package/dist/previews/enumerate.js.map +1 -1
- package/dist/previews/harness.js +1 -1
- package/dist/previews/harness.js.map +1 -1
- package/package.json +4 -7
- package/dist/lib/registry/project-components.d.ts +0 -16
- package/dist/lib/registry/project-components.js.map +0 -1
- 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
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { convexTest } from 'convex-test'
|
|
3
3
|
import { makeFunctionReference } from 'convex/server'
|
|
4
4
|
import schema from './schema'
|
|
5
5
|
|
|
6
|
+
// convex-test fires a `runAfter(0)` schedule on a REAL `setTimeout`. These tests assert
|
|
7
|
+
// only on the `_scheduled_functions` rows, so the real `webhooks:deliver` action (a fetch
|
|
8
|
+
// to host.invalid) must never run: under a loaded full run it failed after this file's
|
|
9
|
+
// environment closed, and convex-test's `console.error` for it then failed the whole run
|
|
10
|
+
// (`EnvironmentTeardownError … onUserConsoleLog`, measured in the wave-0092 gate). Fake
|
|
11
|
+
// timers keep the delivery queued and never fired; the rows are still written.
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers()
|
|
14
|
+
})
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers()
|
|
17
|
+
})
|
|
18
|
+
|
|
6
19
|
/**
|
|
7
|
-
* EC-579
|
|
20
|
+
* EC-579 — an asset edit must reach the live site (the wave-0089 carve-out; since
|
|
21
|
+
* the ruling the trigger sends the typed EVENT `{ kind: 'asset', id }`, and the host
|
|
22
|
+
* receiver answers it with `asset:<id>` + the project sweep).
|
|
8
23
|
*
|
|
9
24
|
* An asset row rides the delivery wire field by field (`delivery.ts`: url, alt,
|
|
10
25
|
* dimensions, focal point, lqip, the `meta` bag), so editing one changes
|
|
@@ -31,8 +46,7 @@ const removeAsset = makeFunctionReference<'mutation'>('assets:remove')
|
|
|
31
46
|
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
32
47
|
|
|
33
48
|
const SESSION_USER_ID = 'user_admin'
|
|
34
|
-
const
|
|
35
|
-
const SWEEP = [`p:${SCOPE}:project`]
|
|
49
|
+
const ASSET_EVENT = { kind: 'asset', id: 'asset_ec579_1' }
|
|
36
50
|
|
|
37
51
|
async function freshBackend() {
|
|
38
52
|
const t = convexTest(schema, modules)
|
|
@@ -48,33 +62,33 @@ async function freshBackend() {
|
|
|
48
62
|
return t
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
/** The scheduled `webhooks:deliver` calls, in order
|
|
52
|
-
async function scheduledRevalidations(
|
|
53
|
-
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
54
|
-
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
65
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
66
|
+
async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
|
|
55
67
|
return t.run(async (ctx) => {
|
|
56
68
|
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
57
69
|
return rows
|
|
58
70
|
.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
|
-
})
|
|
71
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
63
72
|
})
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
/** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
|
|
76
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
77
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
78
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
79
|
+
const previous = keys.map((key) => env[key])
|
|
80
|
+
if (configured) {
|
|
81
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
82
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
83
|
+
} else {
|
|
84
|
+
for (const key of keys) delete env[key]
|
|
73
85
|
}
|
|
74
86
|
return run().finally(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
keys.forEach((key, index) => {
|
|
88
|
+
const value = previous[index]
|
|
89
|
+
if (value === undefined) delete env[key]
|
|
90
|
+
else env[key] = value
|
|
91
|
+
})
|
|
78
92
|
})
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -94,7 +108,7 @@ const assetArgs = {
|
|
|
94
108
|
|
|
95
109
|
describe('EC-579 — the write chokepoint revalidates on every asset edit', () => {
|
|
96
110
|
it('an alt edit schedules a project sweep; the create before it schedules nothing', async () => {
|
|
97
|
-
await
|
|
111
|
+
await withRevalidationEnv(true, async () => {
|
|
98
112
|
const t = await freshBackend()
|
|
99
113
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
100
114
|
|
|
@@ -110,28 +124,24 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
110
124
|
})
|
|
111
125
|
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
112
126
|
|
|
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`).
|
|
127
|
+
// A brand-new asset is on no page yet, so its create invalidates nothing.
|
|
115
128
|
await asAdmin.mutation(upsertAsset, assetArgs)
|
|
116
129
|
expect(await scheduledRevalidations(t)).toHaveLength(1)
|
|
117
130
|
|
|
118
131
|
await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
|
|
119
132
|
|
|
120
133
|
// The edit IS live output: the published snapshot already serves it.
|
|
121
|
-
const snapshot = (await t.query(deliverySnapshot, { projectId:
|
|
134
|
+
const snapshot = (await t.query(deliverySnapshot, { projectId: 'ec579' })) as {
|
|
122
135
|
assets: { alt?: string }[]
|
|
123
136
|
}
|
|
124
137
|
expect(snapshot.assets[0]?.alt).toBe('new')
|
|
125
138
|
|
|
126
|
-
expect(await scheduledRevalidations(t)).toEqual([
|
|
127
|
-
{ event: 'redirects.save', tags: SWEEP },
|
|
128
|
-
{ event: 'assets.upsert', tags: SWEEP },
|
|
129
|
-
])
|
|
139
|
+
expect(await scheduledRevalidations(t)).toEqual([{ kind: 'redirects' }, ASSET_EVENT])
|
|
130
140
|
})
|
|
131
141
|
})
|
|
132
142
|
|
|
133
143
|
it('schedules a sweep on a focal-point edit, a meta edit, a re-upload, and a delete', async () => {
|
|
134
|
-
await
|
|
144
|
+
await withRevalidationEnv(true, async () => {
|
|
135
145
|
const t = await freshBackend()
|
|
136
146
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
137
147
|
|
|
@@ -147,20 +157,16 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
147
157
|
})
|
|
148
158
|
await asAdmin.mutation(removeAsset, { assetId: assetArgs.assetId })
|
|
149
159
|
|
|
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
|
-
])
|
|
160
|
+
// One event per edit and per delete — the same event for both: the receiver
|
|
161
|
+
// derives `asset:<id>` + the sweep either way (a page reading the asset through
|
|
162
|
+
// a document asset FIELD carries no `asset:` tag — `next/src/route-core.ts`
|
|
163
|
+
// `scopeAssetsToPage`, EC-589).
|
|
164
|
+
expect(await scheduledRevalidations(t)).toEqual([ASSET_EVENT, ASSET_EVENT, ASSET_EVENT, ASSET_EVENT])
|
|
159
165
|
})
|
|
160
166
|
})
|
|
161
167
|
|
|
162
168
|
it('removing an asset that does not exist writes nothing and schedules nothing', async () => {
|
|
163
|
-
await
|
|
169
|
+
await withRevalidationEnv(true, async () => {
|
|
164
170
|
const t = await freshBackend()
|
|
165
171
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
166
172
|
await asAdmin.mutation(removeAsset, { assetId: 'asset_never_existed' })
|
|
@@ -168,8 +174,8 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
|
|
|
168
174
|
})
|
|
169
175
|
})
|
|
170
176
|
|
|
171
|
-
it('stays a silent no-op on a deployment
|
|
172
|
-
await
|
|
177
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
|
|
178
|
+
await withRevalidationEnv(false, async () => {
|
|
173
179
|
const t = await freshBackend()
|
|
174
180
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
175
181
|
await asAdmin.mutation(upsertAsset, assetArgs)
|
package/convex/cms.ts
CHANGED
|
@@ -64,9 +64,9 @@ export const upsertDocument = mutation({
|
|
|
64
64
|
// pinned published snapshot — the mutation has both sides, so the compare runs
|
|
65
65
|
// ONCE here, in-transaction (never per list render). A no-op save compares
|
|
66
66
|
// equal → `false` (never flips to true); a revert back to the published
|
|
67
|
-
// content clears a previously-true flag the same way. Schema-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
67
|
+
// content clears a previously-true flag the same way. Schema-free (AD-11 — the
|
|
68
|
+
// deployment stores documents only); the compare is the editor's exact
|
|
69
|
+
// `documentsSemanticEqual`, the ONE def-less verdict over canonical forms (EC-586). Unpublished doc / missing pinned row →
|
|
70
70
|
// absent-key patch preserves the row as-is (the flag doesn't apply).
|
|
71
71
|
let changedFlag = {}
|
|
72
72
|
if (existing.publishedVersion !== undefined) {
|
|
@@ -82,7 +82,6 @@ export const upsertDocument = mutation({
|
|
|
82
82
|
if (pinned) {
|
|
83
83
|
changedFlag = {
|
|
84
84
|
hasUnpublishedChanges: !documentsSemanticEqual(
|
|
85
|
-
undefined,
|
|
86
85
|
unfoldDocument(pinned.snapshot as CmsDocument),
|
|
87
86
|
args.document as CmsDocument,
|
|
88
87
|
),
|
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,188 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import schema from './schema'
|
|
5
|
+
|
|
6
|
+
// convex-test fires a `runAfter(0)` schedule on a REAL `setTimeout`. These tests assert
|
|
7
|
+
// only on the `_scheduled_functions` rows, so the real `webhooks:deliver` action (a fetch
|
|
8
|
+
// to host.invalid) must never run: under a loaded full run it failed after this file's
|
|
9
|
+
// environment closed, and convex-test's `console.error` for it then failed the whole run
|
|
10
|
+
// (`EnvironmentTeardownError … onUserConsoleLog`, measured in the wave-0092 gate). Fake
|
|
11
|
+
// timers keep the delivery queued and never fired; the rows are still written.
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers()
|
|
14
|
+
})
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EC-579 — the write chokepoint is the ONE revalidation emitter, and it sends the
|
|
21
|
+
* EVENT, not tags.
|
|
22
|
+
*
|
|
23
|
+
* The REAL `cms:*` mutations through the REAL `functions.ts` trigger wrapper on
|
|
24
|
+
* `convex-test`'s in-memory backend, asserting on the `_scheduled_functions` system
|
|
25
|
+
* table: what `scheduleRevalidate` hands the `webhooks:deliver` action. The host
|
|
26
|
+
* receiver derives the tags from the event with its own scope and locales
|
|
27
|
+
* (`@elytracms/next` `revalidationTags`), so nothing here knows a tag format or a
|
|
28
|
+
* scope — the event carries only what changed.
|
|
29
|
+
*
|
|
30
|
+
* Documents reach the live wire only through their published pin (`delivery.ts`),
|
|
31
|
+
* so only a pin move or the delete of a pinned row is an event; a draft save, a
|
|
32
|
+
* version snapshot and the delete of a never-published row are not.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const modules = {
|
|
36
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
37
|
+
'./_generated/api.ts': async () => ({}),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const upsertDocument = makeFunctionReference<'mutation'>('cms:upsertDocument')
|
|
41
|
+
const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
|
|
42
|
+
const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
|
|
43
|
+
const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
|
|
44
|
+
const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
|
|
45
|
+
const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
|
|
46
|
+
|
|
47
|
+
const SESSION_USER_ID = 'user_admin'
|
|
48
|
+
const STAMP = '2026-10-25T00:00:00.000Z'
|
|
49
|
+
|
|
50
|
+
async function freshBackend() {
|
|
51
|
+
const t = convexTest(schema, modules)
|
|
52
|
+
await t.run(async (ctx) => {
|
|
53
|
+
await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: STAMP, updatedAt: STAMP })
|
|
54
|
+
})
|
|
55
|
+
return { t, asAdmin: t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' }) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Backend = Awaited<ReturnType<typeof freshBackend>>
|
|
59
|
+
|
|
60
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
61
|
+
async function scheduledEvents({ t }: Backend): Promise<unknown[]> {
|
|
62
|
+
return t.run(async (ctx) => {
|
|
63
|
+
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
64
|
+
return rows
|
|
65
|
+
.filter((row) => row.name.startsWith('webhooks'))
|
|
66
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The Convex gate: the endpoint + secret pair the `deliver` action posts with. */
|
|
71
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
72
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
73
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
74
|
+
const previous = keys.map((key) => env[key])
|
|
75
|
+
if (configured) {
|
|
76
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
77
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
78
|
+
} else {
|
|
79
|
+
for (const key of keys) delete env[key]
|
|
80
|
+
}
|
|
81
|
+
return run().finally(() => {
|
|
82
|
+
keys.forEach((key, index) => {
|
|
83
|
+
const value = previous[index]
|
|
84
|
+
if (value === undefined) delete env[key]
|
|
85
|
+
else env[key] = value
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function doc(collection: string, id: string, values: Record<string, unknown>) {
|
|
91
|
+
return { collection, id, values }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function saveDraft({ asAdmin }: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
95
|
+
await asAdmin.mutation(upsertDocument, {
|
|
96
|
+
collection,
|
|
97
|
+
docId: id,
|
|
98
|
+
document: doc(collection, id, values),
|
|
99
|
+
createdAt: STAMP,
|
|
100
|
+
updatedAt: STAMP,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
|
|
105
|
+
const version = (await backend.asAdmin.mutation(saveVersion, {
|
|
106
|
+
collection,
|
|
107
|
+
docId: id,
|
|
108
|
+
snapshot: doc(collection, id, values),
|
|
109
|
+
createdAt: STAMP,
|
|
110
|
+
})) as { version: number }
|
|
111
|
+
await backend.asAdmin.mutation(publishVersion, {
|
|
112
|
+
collection,
|
|
113
|
+
docId: id,
|
|
114
|
+
version: version.version,
|
|
115
|
+
updatedAt: STAMP,
|
|
116
|
+
publishedAt: STAMP,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('EC-579 — documents: one event per change to the live pin', () => {
|
|
121
|
+
it('publish, republish, unpublish and delete each schedule exactly one event with collection + id', async () => {
|
|
122
|
+
await withRevalidationEnv(true, async () => {
|
|
123
|
+
const backend = await freshBackend()
|
|
124
|
+
await saveDraft(backend, 'post', 'hello', { title: 'Draft' })
|
|
125
|
+
// A draft save is not live output: nothing scheduled.
|
|
126
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
127
|
+
|
|
128
|
+
await publish(backend, 'post', 'hello', { title: 'v1' })
|
|
129
|
+
await publish(backend, 'post', 'hello', { title: 'v2' })
|
|
130
|
+
await backend.asAdmin.mutation(unpublishDocument, { collection: 'post', docId: 'hello', updatedAt: STAMP })
|
|
131
|
+
await publish(backend, 'post', 'hello', { title: 'v3' })
|
|
132
|
+
await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'hello' })
|
|
133
|
+
|
|
134
|
+
expect(await scheduledEvents(backend)).toEqual([
|
|
135
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
136
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
137
|
+
{ kind: 'unpublish', collection: 'post', id: 'hello' },
|
|
138
|
+
{ kind: 'publish', collection: 'post', id: 'hello' },
|
|
139
|
+
{ kind: 'delete', collection: 'post', id: 'hello' },
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('a draft save on a PUBLISHED document, and the delete of a never-published one, schedule nothing', async () => {
|
|
145
|
+
await withRevalidationEnv(true, async () => {
|
|
146
|
+
const backend = await freshBackend()
|
|
147
|
+
await saveDraft(backend, 'post', 'live', { title: 'v1' })
|
|
148
|
+
await publish(backend, 'post', 'live', { title: 'v1' })
|
|
149
|
+
// Known-positive control: this instrument sees the publish.
|
|
150
|
+
expect(await scheduledEvents(backend)).toHaveLength(1)
|
|
151
|
+
|
|
152
|
+
await saveDraft(backend, 'post', 'live', { title: 'edited draft' })
|
|
153
|
+
await saveDraft(backend, 'post', 'never', { title: 'draft only' })
|
|
154
|
+
await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'never' })
|
|
155
|
+
expect(await scheduledEvents(backend)).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The wave-0089 finding (EC-579 Done § Found-but-left 4): a draft write to a
|
|
161
|
+
* `cms-data-sources` record changed the public snapshot and no revalidation fired.
|
|
162
|
+
* EC-574 deleted the snapshot's `sources` strand; this pins that it stays closed —
|
|
163
|
+
* the draft row is not on the published wire, so silence is correct.
|
|
164
|
+
*/
|
|
165
|
+
it('a cms-data-sources draft write changes no published output and schedules nothing', async () => {
|
|
166
|
+
await withRevalidationEnv(true, async () => {
|
|
167
|
+
const backend = await freshBackend()
|
|
168
|
+
const before = await backend.t.query(deliverySnapshot, { projectId: 'p' })
|
|
169
|
+
await saveDraft(backend, 'cms-data-sources', 'source-1', { title: 'A data source' })
|
|
170
|
+
const after = await backend.t.query(deliverySnapshot, { projectId: 'p' })
|
|
171
|
+
expect(after).toEqual(before)
|
|
172
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair — the write still lands', async () => {
|
|
177
|
+
await withRevalidationEnv(false, async () => {
|
|
178
|
+
const backend = await freshBackend()
|
|
179
|
+
await saveDraft(backend, 'post', 'hello', { title: 'v1' })
|
|
180
|
+
await publish(backend, 'post', 'hello', { title: 'v1' })
|
|
181
|
+
// The first test is the known-positive control for the same publish.
|
|
182
|
+
expect(await scheduledEvents(backend)).toEqual([])
|
|
183
|
+
expect(
|
|
184
|
+
await backend.t.run(async (ctx) => (await ctx.db.query('cmsDocuments').first())?.publishedVersion),
|
|
185
|
+
).toBe(1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { convexTest } from 'convex-test'
|
|
3
3
|
import { makeFunctionReference } from 'convex/server'
|
|
4
4
|
import schema from './schema'
|
|
5
5
|
|
|
6
|
+
// convex-test fires a `runAfter(0)` schedule on a REAL `setTimeout`. These tests assert
|
|
7
|
+
// only on the `_scheduled_functions` rows, so the real `webhooks:deliver` action (a fetch
|
|
8
|
+
// to host.invalid) must never run: under a loaded full run it failed after this file's
|
|
9
|
+
// environment closed, and convex-test's `console.error` for it then failed the whole run
|
|
10
|
+
// (`EnvironmentTeardownError … onUserConsoleLog`, measured in the wave-0092 gate). Fake
|
|
11
|
+
// timers keep the delivery queued and never fired; the rows are still written.
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers()
|
|
14
|
+
})
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers()
|
|
17
|
+
})
|
|
18
|
+
|
|
6
19
|
/**
|
|
7
|
-
* EC-510 —
|
|
20
|
+
* EC-510 — "a redirect change must reach the live site".
|
|
8
21
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
22
|
+
* The EC-286 write chokepoint is the ONE emitter (EC-579 deleted the studio's
|
|
23
|
+
* browser dispatcher), and it catches writers the studio does not own. Before
|
|
24
|
+
* EC-510 a redirect write reached the host by NO path, while a document publish
|
|
25
|
+
* reached it by two.
|
|
13
26
|
*
|
|
14
27
|
* This runs the REAL `redirects.save`/`redirects.remove` mutations through the
|
|
15
28
|
* REAL `functions.ts` trigger wrapper on `convex-test`'s in-memory backend, and
|
|
@@ -17,10 +30,10 @@ import schema from './schema'
|
|
|
17
30
|
* `scheduleRevalidate` actually produces (a mutation cannot `fetch`, so it hands
|
|
18
31
|
* the signed POST to the `webhooks:deliver` action).
|
|
19
32
|
*
|
|
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.
|
|
33
|
+
* The endpoint/secret pair is the gate `scheduleRevalidate` reads: an
|
|
34
|
+
* unconfigured deployment must stay a silent no-op, because publishing may never
|
|
35
|
+
* depend on a reachable host. Both sides are pinned here so neither can regress
|
|
36
|
+
* into the other.
|
|
24
37
|
*/
|
|
25
38
|
|
|
26
39
|
const modules = {
|
|
@@ -34,7 +47,6 @@ const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
|
|
|
34
47
|
const removeRedirect = makeFunctionReference<'mutation'>('redirects:remove')
|
|
35
48
|
|
|
36
49
|
const SESSION_USER_ID = 'user_admin'
|
|
37
|
-
const SCOPE = 'ec510-scope'
|
|
38
50
|
|
|
39
51
|
async function freshBackend() {
|
|
40
52
|
const t = convexTest(schema, modules)
|
|
@@ -50,33 +62,33 @@ async function freshBackend() {
|
|
|
50
62
|
return t
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
/** The scheduled `webhooks:deliver` calls, in order
|
|
54
|
-
async function scheduledRevalidations(
|
|
55
|
-
t: Awaited<ReturnType<typeof freshBackend>>,
|
|
56
|
-
): Promise<{ event: unknown; tags: unknown }[]> {
|
|
65
|
+
/** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
|
|
66
|
+
async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
|
|
57
67
|
return t.run(async (ctx) => {
|
|
58
68
|
const rows = await ctx.db.system.query('_scheduled_functions').collect()
|
|
59
69
|
return rows
|
|
60
70
|
.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
|
-
})
|
|
71
|
+
.map((row) => (row.args as [{ event?: unknown }])[0]?.event)
|
|
65
72
|
})
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
/** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
|
|
76
|
+
function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
|
|
77
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
78
|
+
const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
|
|
79
|
+
const previous = keys.map((key) => env[key])
|
|
80
|
+
if (configured) {
|
|
81
|
+
env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
|
|
82
|
+
env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
|
|
83
|
+
} else {
|
|
84
|
+
for (const key of keys) delete env[key]
|
|
75
85
|
}
|
|
76
86
|
return run().finally(() => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
keys.forEach((key, index) => {
|
|
88
|
+
const value = previous[index]
|
|
89
|
+
if (value === undefined) delete env[key]
|
|
90
|
+
else env[key] = value
|
|
91
|
+
})
|
|
80
92
|
})
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -91,8 +103,8 @@ const redirectArgs = {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
describe('EC-510 — the write chokepoint revalidates on every redirect write', () => {
|
|
94
|
-
it('schedules
|
|
95
|
-
await
|
|
106
|
+
it('schedules one `redirects` event on create, retarget, disable, and delete', async () => {
|
|
107
|
+
await withRevalidationEnv(true, async () => {
|
|
96
108
|
const t = await freshBackend()
|
|
97
109
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
98
110
|
|
|
@@ -103,17 +115,18 @@ describe('EC-510 — the write chokepoint revalidates on every redirect write',
|
|
|
103
115
|
await asAdmin.mutation(saveRedirect, { ...redirectArgs, enabled: false })
|
|
104
116
|
await asAdmin.mutation(removeRedirect, { redirectId: redirectArgs.redirectId })
|
|
105
117
|
|
|
118
|
+
// The receiver answers each with the project sweep (`revalidationTags`).
|
|
106
119
|
expect(await scheduledRevalidations(t)).toEqual([
|
|
107
|
-
{
|
|
108
|
-
{
|
|
109
|
-
{
|
|
110
|
-
{
|
|
120
|
+
{ kind: 'redirects' },
|
|
121
|
+
{ kind: 'redirects' },
|
|
122
|
+
{ kind: 'redirects' },
|
|
123
|
+
{ kind: 'redirects' },
|
|
111
124
|
])
|
|
112
125
|
})
|
|
113
126
|
})
|
|
114
127
|
|
|
115
|
-
it('stays a silent no-op on a deployment
|
|
116
|
-
await
|
|
128
|
+
it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
|
|
129
|
+
await withRevalidationEnv(false, async () => {
|
|
117
130
|
const t = await freshBackend()
|
|
118
131
|
const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
|
|
119
132
|
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
|
}
|