@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.
Files changed (246) hide show
  1. package/convex/asset-revalidate.test.ts +49 -43
  2. package/convex/cms.ts +3 -4
  3. package/convex/delivery.ts +8 -11
  4. package/convex/document-revalidate.test.ts +188 -0
  5. package/convex/redirect-revalidate.test.ts +50 -37
  6. package/convex/revalidate.ts +18 -30
  7. package/convex/triggers.ts +47 -38
  8. package/convex/webhooks.ts +77 -62
  9. package/dist/components/account/self-account-view-source.js +2 -1
  10. package/dist/components/account/self-account-view-source.js.map +1 -1
  11. package/dist/components/ai/ai-chat-panel.js +1 -1
  12. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  13. package/dist/components/app-views/default-modules.js +3 -3
  14. package/dist/components/app-views/default-modules.js.map +1 -1
  15. package/dist/components/assets/asset-grid.js +1 -1
  16. package/dist/components/assets/asset-grid.js.map +1 -1
  17. package/dist/components/assets/asset-selection.d.ts +8 -9
  18. package/dist/components/assets/asset-selection.js +26 -16
  19. package/dist/components/assets/asset-selection.js.map +1 -1
  20. package/dist/components/assets/assets-collection-view-source.js +3 -2
  21. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  22. package/dist/components/builder/blocks-model.d.ts +7 -5
  23. package/dist/components/builder/blocks-model.js +11 -9
  24. package/dist/components/builder/blocks-model.js.map +1 -1
  25. package/dist/components/builder/blocks-panel.js +1 -1
  26. package/dist/components/builder/blocks-panel.js.map +1 -1
  27. package/dist/components/builder/builder-store.d.ts +11 -3
  28. package/dist/components/builder/builder-store.js +12 -5
  29. package/dist/components/builder/builder-store.js.map +1 -1
  30. package/dist/components/builder/canvas-dnd.d.ts +7 -1
  31. package/dist/components/builder/canvas-dnd.js +6 -3
  32. package/dist/components/builder/canvas-dnd.js.map +1 -1
  33. package/dist/components/builder/canvas-render-context.d.ts +13 -1
  34. package/dist/components/builder/canvas-render-context.js +28 -18
  35. package/dist/components/builder/canvas-render-context.js.map +1 -1
  36. package/dist/components/builder/canvas.js +1 -1
  37. package/dist/components/builder/canvas.js.map +1 -1
  38. package/dist/components/builder/clipboard-actions.d.ts +0 -6
  39. package/dist/components/builder/clipboard-actions.js +9 -4
  40. package/dist/components/builder/clipboard-actions.js.map +1 -1
  41. package/dist/components/builder/composition-field-editor.d.ts +22 -0
  42. package/dist/components/builder/composition-field-editor.js +47 -8
  43. package/dist/components/builder/composition-field-editor.js.map +1 -1
  44. package/dist/components/builder/inline-edit.d.ts +7 -4
  45. package/dist/components/builder/inline-edit.js +11 -8
  46. package/dist/components/builder/inline-edit.js.map +1 -1
  47. package/dist/components/builder/insert-context.js +9 -5
  48. package/dist/components/builder/insert-context.js.map +1 -1
  49. package/dist/components/builder/layout-inspector.d.ts +2 -2
  50. package/dist/components/builder/layout-inspector.js +8 -6
  51. package/dist/components/builder/layout-inspector.js.map +1 -1
  52. package/dist/components/builder/node-factory.d.ts +31 -14
  53. package/dist/components/builder/node-factory.js +73 -93
  54. package/dist/components/builder/node-factory.js.map +1 -1
  55. package/dist/components/builder/page-tree.d.ts +3 -1
  56. package/dist/components/builder/page-tree.js +6 -4
  57. package/dist/components/builder/page-tree.js.map +1 -1
  58. package/dist/components/builder/property-inspector.js +2 -1
  59. package/dist/components/builder/property-inspector.js.map +1 -1
  60. package/dist/components/builder/reuse-context.js +2 -2
  61. package/dist/components/builder/reuse-context.js.map +1 -1
  62. package/dist/components/builder/reuse.d.ts +7 -3
  63. package/dist/components/builder/reuse.js +19 -11
  64. package/dist/components/builder/reuse.js.map +1 -1
  65. package/dist/components/builder/selection-overlay.d.ts +2 -2
  66. package/dist/components/builder/selection-overlay.js +3 -3
  67. package/dist/components/builder/selection-overlay.js.map +1 -1
  68. package/dist/components/builder/spacing-handles.d.ts +2 -2
  69. package/dist/components/builder/spacing-handles.js +3 -3
  70. package/dist/components/builder/spacing-handles.js.map +1 -1
  71. package/dist/components/cms/asset-field.d.ts +1 -1
  72. package/dist/components/cms/asset-field.js +15 -13
  73. package/dist/components/cms/asset-field.js.map +1 -1
  74. package/dist/components/cms/asset-picker-core.js +3 -4
  75. package/dist/components/cms/asset-picker-core.js.map +1 -1
  76. package/dist/components/cms/cell-edit.d.ts +0 -6
  77. package/dist/components/cms/cell-edit.js +5 -23
  78. package/dist/components/cms/cell-edit.js.map +1 -1
  79. package/dist/components/cms/cms-store.d.ts +3 -2
  80. package/dist/components/cms/cms-store.js +23 -24
  81. package/dist/components/cms/cms-store.js.map +1 -1
  82. package/dist/components/cms/collection-table.js +14 -4
  83. package/dist/components/cms/collection-table.js.map +1 -1
  84. package/dist/components/cms/detail-editor.js +28 -24
  85. package/dist/components/cms/detail-editor.js.map +1 -1
  86. package/dist/components/cms/discard-draft.d.ts +1 -2
  87. package/dist/components/cms/discard-draft.js +1 -1
  88. package/dist/components/cms/discard-draft.js.map +1 -1
  89. package/dist/components/cms/duplicate-document.d.ts +3 -2
  90. package/dist/components/cms/duplicate-document.js +5 -5
  91. package/dist/components/cms/duplicate-document.js.map +1 -1
  92. package/dist/components/cms/editable-cell.js +11 -7
  93. package/dist/components/cms/editable-cell.js.map +1 -1
  94. package/dist/components/cms/field-control-lib.d.ts +10 -0
  95. package/dist/components/cms/field-control-lib.js +20 -0
  96. package/dist/components/cms/field-control-lib.js.map +1 -1
  97. package/dist/components/cms/field-control.js +10 -9
  98. package/dist/components/cms/field-control.js.map +1 -1
  99. package/dist/components/cms/object-array-field.js +3 -5
  100. package/dist/components/cms/object-array-field.js.map +1 -1
  101. package/dist/components/cms/prop-fields.d.ts +16 -3
  102. package/dist/components/cms/prop-fields.js +57 -12
  103. package/dist/components/cms/prop-fields.js.map +1 -1
  104. package/dist/components/cms/publish-clean.d.ts +13 -6
  105. package/dist/components/cms/publish-clean.js +14 -6
  106. package/dist/components/cms/publish-clean.js.map +1 -1
  107. package/dist/components/cms/rich-text/embed-props.d.ts +6 -1
  108. package/dist/components/cms/rich-text/embed-props.js +7 -12
  109. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  110. package/dist/components/cms/rich-text/insert.d.ts +13 -6
  111. package/dist/components/cms/rich-text/insert.js +16 -17
  112. package/dist/components/cms/rich-text/insert.js.map +1 -1
  113. package/dist/components/cms/use-cell-edit.js +7 -12
  114. package/dist/components/cms/use-cell-edit.js.map +1 -1
  115. package/dist/components/cms/use-discard-controller.d.ts +1 -2
  116. package/dist/components/cms/use-discard-controller.js +1 -2
  117. package/dist/components/cms/use-discard-controller.js.map +1 -1
  118. package/dist/components/cms/use-publish-controller.js +3 -2
  119. package/dist/components/cms/use-publish-controller.js.map +1 -1
  120. package/dist/components/cms/versions-pane.js +2 -2
  121. package/dist/components/cms/versions-pane.js.map +1 -1
  122. package/dist/components/components/component-detail.d.ts +1 -1
  123. package/dist/components/components/component-detail.js +2 -4
  124. package/dist/components/components/component-detail.js.map +1 -1
  125. package/dist/components/components/component-preview.js +9 -4
  126. package/dist/components/components/component-preview.js.map +1 -1
  127. package/dist/components/components/components-index.d.ts +2 -2
  128. package/dist/components/components/components-index.js +3 -6
  129. package/dist/components/components/components-index.js.map +1 -1
  130. package/dist/components/components/field-inspector.js +5 -2
  131. package/dist/components/components/field-inspector.js.map +1 -1
  132. package/dist/components/components/registry-browser.d.ts +6 -5
  133. package/dist/components/components/registry-browser.js +11 -37
  134. package/dist/components/components/registry-browser.js.map +1 -1
  135. package/dist/components/components/taxonomy.d.ts +14 -21
  136. package/dist/components/components/taxonomy.js +5 -29
  137. package/dist/components/components/taxonomy.js.map +1 -1
  138. package/dist/components/components/transfer-dialog.js +1 -0
  139. package/dist/components/components/transfer-dialog.js.map +1 -1
  140. package/dist/components/components/transfer.d.ts +20 -12
  141. package/dist/components/components/transfer.js +27 -21
  142. package/dist/components/components/transfer.js.map +1 -1
  143. package/dist/components/delivery-health-banner.d.ts +3 -2
  144. package/dist/components/delivery-health-banner.js +3 -2
  145. package/dist/components/delivery-health-banner.js.map +1 -1
  146. package/dist/components/redirects/redirects-collection-view-source.js +3 -2
  147. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  148. package/dist/components/users/users-collection-view-source.js +2 -1
  149. package/dist/components/users/users-collection-view-source.js.map +1 -1
  150. package/dist/lib/ai/apply-content.js +32 -28
  151. package/dist/lib/ai/apply-content.js.map +1 -1
  152. package/dist/lib/ai/constraints.d.ts +16 -14
  153. package/dist/lib/ai/constraints.js +64 -48
  154. package/dist/lib/ai/constraints.js.map +1 -1
  155. package/dist/lib/ai/translate-document.js +11 -7
  156. package/dist/lib/ai/translate-document.js.map +1 -1
  157. package/dist/lib/ai/types.d.ts +9 -5
  158. package/dist/lib/ai/types.js.map +1 -1
  159. package/dist/lib/ai/update-fields.js +15 -32
  160. package/dist/lib/ai/update-fields.js.map +1 -1
  161. package/dist/lib/ai/vite.d.ts +22 -2
  162. package/dist/lib/ai/vite.js +12 -3
  163. package/dist/lib/ai/vite.js.map +1 -1
  164. package/dist/lib/assets/asset-placements.d.ts +3 -10
  165. package/dist/lib/assets/asset-placements.js +17 -8
  166. package/dist/lib/assets/asset-placements.js.map +1 -1
  167. package/dist/lib/clipboard/paste-target.js +3 -3
  168. package/dist/lib/clipboard/paste-target.js.map +1 -1
  169. package/dist/lib/cms/published-documents.d.ts +14 -3
  170. package/dist/lib/cms/published-documents.js +12 -3
  171. package/dist/lib/cms/published-documents.js.map +1 -1
  172. package/dist/lib/cms-views/view-model.d.ts +4 -4
  173. package/dist/lib/cms-views/view-model.js +26 -20
  174. package/dist/lib/cms-views/view-model.js.map +1 -1
  175. package/dist/lib/data/collection-content.d.ts +10 -7
  176. package/dist/lib/data/collection-content.js +70 -29
  177. package/dist/lib/data/collection-content.js.map +1 -1
  178. package/dist/lib/delivery/instant-publish.d.ts +0 -3
  179. package/dist/lib/delivery/instant-publish.js +0 -1
  180. package/dist/lib/delivery/instant-publish.js.map +1 -1
  181. package/dist/lib/dnd/drop-rules.d.ts +22 -7
  182. package/dist/lib/dnd/drop-rules.js +21 -14
  183. package/dist/lib/dnd/drop-rules.js.map +1 -1
  184. package/dist/lib/engine-version.d.ts +1 -1
  185. package/dist/lib/engine-version.js +1 -1
  186. package/dist/lib/engine-version.js.map +1 -1
  187. package/dist/lib/i18n/messages.de.js +25 -9
  188. package/dist/lib/i18n/messages.de.js.map +1 -1
  189. package/dist/lib/i18n/messages.en.d.ts +21 -7
  190. package/dist/lib/i18n/messages.en.js +28 -9
  191. package/dist/lib/i18n/messages.en.js.map +1 -1
  192. package/dist/lib/locale/fallback.d.ts +5 -19
  193. package/dist/lib/locale/fallback.js +18 -39
  194. package/dist/lib/locale/fallback.js.map +1 -1
  195. package/dist/lib/locale/locale-store.js +2 -1
  196. package/dist/lib/locale/locale-store.js.map +1 -1
  197. package/dist/lib/operations/cms-persistence.js +7 -4
  198. package/dist/lib/operations/cms-persistence.js.map +1 -1
  199. package/dist/lib/operations/studio-operations.js +4 -6
  200. package/dist/lib/operations/studio-operations.js.map +1 -1
  201. package/dist/lib/persistence/backend-mode.d.ts +5 -25
  202. package/dist/lib/persistence/backend-mode.js +13 -30
  203. package/dist/lib/persistence/backend-mode.js.map +1 -1
  204. package/dist/lib/persona/labels.d.ts +4 -9
  205. package/dist/lib/persona/labels.js +5 -12
  206. package/dist/lib/persona/labels.js.map +1 -1
  207. package/dist/lib/references/reference-model.d.ts +17 -5
  208. package/dist/lib/references/reference-model.js +20 -28
  209. package/dist/lib/references/reference-model.js.map +1 -1
  210. package/dist/lib/references/references-store.js +7 -0
  211. package/dist/lib/references/references-store.js.map +1 -1
  212. package/dist/lib/registry/__fixtures__/demo-components.d.ts +32 -0
  213. package/dist/lib/registry/{project-components.js → __fixtures__/demo-components.js} +18 -5
  214. package/dist/lib/registry/__fixtures__/demo-components.js.map +1 -0
  215. package/dist/lib/registry/index.d.ts +9 -13
  216. package/dist/lib/registry/index.js +34 -61
  217. package/dist/lib/registry/index.js.map +1 -1
  218. package/dist/lib/registry/preview.d.ts +22 -7
  219. package/dist/lib/registry/preview.js +26 -89
  220. package/dist/lib/registry/preview.js.map +1 -1
  221. package/dist/lib/workspace/index.d.ts +0 -7
  222. package/dist/lib/workspace/index.js +0 -9
  223. package/dist/lib/workspace/index.js.map +1 -1
  224. package/dist/previews/enumerate.d.ts +23 -27
  225. package/dist/previews/enumerate.js +27 -48
  226. package/dist/previews/enumerate.js.map +1 -1
  227. package/dist/previews/harness.js +1 -1
  228. package/dist/previews/harness.js.map +1 -1
  229. package/package.json +4 -7
  230. package/dist/lib/registry/project-components.d.ts +0 -16
  231. package/dist/lib/registry/project-components.js.map +0 -1
  232. package/dist/lib/webhooks/config.d.ts +0 -15
  233. package/dist/lib/webhooks/config.js +0 -65
  234. package/dist/lib/webhooks/config.js.map +0 -1
  235. package/dist/lib/webhooks/dispatcher.d.ts +0 -70
  236. package/dist/lib/webhooks/dispatcher.js +0 -61
  237. package/dist/lib/webhooks/dispatcher.js.map +0 -1
  238. package/dist/lib/webhooks/index.d.ts +0 -11
  239. package/dist/lib/webhooks/index.js +0 -10
  240. package/dist/lib/webhooks/index.js.map +0 -1
  241. package/dist/lib/webhooks/signing.d.ts +0 -15
  242. package/dist/lib/webhooks/signing.js +0 -24
  243. package/dist/lib/webhooks/signing.js.map +0 -1
  244. package/dist/lib/webhooks/tags.d.ts +0 -25
  245. package/dist/lib/webhooks/tags.js +0 -289
  246. 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 (no-owner-call carve-out) — an asset edit must reach the live site.
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 SCOPE = 'ec579-scope'
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, as `{event, tags}`. */
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
- 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
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
- if (!env) return
76
- if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
77
- else env['CANVAS_PROJECT_SCOPE'] = previous
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 withScope(SCOPE, async () => {
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: SCOPE })) as {
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 withScope(SCOPE, async () => {
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
- // 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
- ])
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 withScope(SCOPE, async () => {
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 with no CANVAS_PROJECT_SCOPE', async () => {
172
- await withScope(undefined, async () => {
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-less lens
68
- // (AD-11 — the deployment stores documents only); the compare is the editor's
69
- // exact `documentsSemanticEqual`. Unpublished doc / missing pinned row →
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
  ),
@@ -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 these: a publish only
215
- * schedules the webhook when `CANVAS_PROJECT_SCOPE` is set (the write-chokepoint
216
- * trigger's `scheduleRevalidate`, `triggers.ts`), and the webhook only fires when
217
- * `CANVAS_REVALIDATE_ENDPOINT` + `CANVAS_REVALIDATE_SECRET` are set
218
- * (`webhooks.ts` `deliver`). A future `elytra doctor` (EC-259) can read the same
219
- * query. Gated to deployment members (EC-304) — config state is not public, and
220
- * an authenticated non-member (open Password sign-up) is not a member.
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 — the Convex half of "a redirect change must reach the live site".
20
+ * EC-510 — "a redirect change must reach the live site".
8
21
  *
9
- * The browser dispatcher (`src/lib/webhooks`) is one emitter; the EC-286 write
10
- * chokepoint is the other, and it is the one that catches writers the studio
11
- * does not own. Before this ticket a redirect write reached the host by
12
- * NEITHER, while a document publish reached it by both.
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 env var is the gate `scheduleRevalidate` reads: an unconfigured
21
- * deployment must stay a silent no-op, because publishing may never depend on a
22
- * reachable host. Both sides are pinned here so neither can regress into the
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, as `{event, tags}`. */
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
- function withScope<T>(scope: string | undefined, run: () => Promise<T>): Promise<T> {
69
- const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
70
- ?.env
71
- const previous = env?.['CANVAS_PROJECT_SCOPE']
72
- if (env) {
73
- if (scope === undefined) delete env['CANVAS_PROJECT_SCOPE']
74
- else env['CANVAS_PROJECT_SCOPE'] = scope
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
- if (!env) return
78
- if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
79
- else env['CANVAS_PROJECT_SCOPE'] = previous
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 a project sweep on create, retarget, disable, and delete', async () => {
95
- await withScope(SCOPE, async () => {
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
- { event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
108
- { event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
109
- { event: 'redirects.save', tags: [`p:${SCOPE}:project`] },
110
- { event: 'redirects.remove', tags: [`p:${SCOPE}:project`] },
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 with no CANVAS_PROJECT_SCOPE', async () => {
116
- await withScope(undefined, async () => {
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)
@@ -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 after a publish-state
11
- * change. A mutation cannot `fetch`, so it hands off to the `webhooks.deliver`
12
- * internal action (it signs + POSTs server-side; the secret never leaves Convex).
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 v1 invalidation is the whole-project sweep tag `p:<scope>:project` — every
15
- * cached host page carries it (`@elytracms/next` `nextCacheTags` stamps it on
16
- * every entry), so it is GUARANTEED to drop the affected pages with no tag-format
17
- * coupling that could silently miss. `scope` must equal the host's `cache.scope`
18
- * (its `ELYTRA_PROJECT_ID`); it comes from the `CANVAS_PROJECT_SCOPE` Convex env
19
- * var. An unconfigured deployment (local dev, no env) is a silent no-op —
20
- * publishing must never depend on a reachable host.
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: string): Promise<void> {
37
- const env =
38
- (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
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
  }