@elytracms/studio 0.0.87 → 0.0.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/convex/asset-revalidate.test.ts +35 -42
  2. package/convex/delivery.ts +8 -11
  3. package/convex/document-revalidate.test.ts +175 -0
  4. package/convex/redirect-revalidate.test.ts +36 -36
  5. package/convex/revalidate.ts +18 -30
  6. package/convex/triggers.ts +47 -38
  7. package/convex/webhooks.ts +77 -62
  8. package/dist/components/account/self-account-view-source.js +2 -1
  9. package/dist/components/account/self-account-view-source.js.map +1 -1
  10. package/dist/components/ai/ai-chat-panel.js +9 -2
  11. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  12. package/dist/components/app-views/default-modules.js +3 -3
  13. package/dist/components/app-views/default-modules.js.map +1 -1
  14. package/dist/components/assets/asset-grid.js +1 -1
  15. package/dist/components/assets/asset-grid.js.map +1 -1
  16. package/dist/components/assets/assets-collection-view-source.js +3 -2
  17. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  18. package/dist/components/assets/assets-store.d.ts +11 -1
  19. package/dist/components/assets/assets-store.js +10 -0
  20. package/dist/components/assets/assets-store.js.map +1 -1
  21. package/dist/components/assets/focal-point.d.ts +0 -7
  22. package/dist/components/assets/focal-point.js +0 -10
  23. package/dist/components/assets/focal-point.js.map +1 -1
  24. package/dist/components/builder/canvas-render-context.d.ts +12 -1
  25. package/dist/components/builder/canvas-render-context.js +20 -16
  26. package/dist/components/builder/canvas-render-context.js.map +1 -1
  27. package/dist/components/builder/reuse-context.js +2 -2
  28. package/dist/components/builder/reuse-context.js.map +1 -1
  29. package/dist/components/builder/reuse.d.ts +6 -2
  30. package/dist/components/builder/reuse.js +12 -7
  31. package/dist/components/builder/reuse.js.map +1 -1
  32. package/dist/components/cms/asset-field.d.ts +2 -1
  33. package/dist/components/cms/asset-field.js +27 -59
  34. package/dist/components/cms/asset-field.js.map +1 -1
  35. package/dist/components/cms/asset-picker-core.d.ts +77 -40
  36. package/dist/components/cms/asset-picker-core.js +73 -46
  37. package/dist/components/cms/asset-picker-core.js.map +1 -1
  38. package/dist/components/cms/asset-picker.d.ts +48 -19
  39. package/dist/components/cms/asset-picker.js +69 -30
  40. package/dist/components/cms/asset-picker.js.map +1 -1
  41. package/dist/components/cms/cell-edit.d.ts +3 -7
  42. package/dist/components/cms/cell-edit.js +7 -25
  43. package/dist/components/cms/cell-edit.js.map +1 -1
  44. package/dist/components/cms/cms-store.d.ts +3 -2
  45. package/dist/components/cms/cms-store.js +38 -47
  46. package/dist/components/cms/cms-store.js.map +1 -1
  47. package/dist/components/cms/collection-table.js +14 -4
  48. package/dist/components/cms/collection-table.js.map +1 -1
  49. package/dist/components/cms/detail-editor.js +20 -21
  50. package/dist/components/cms/detail-editor.js.map +1 -1
  51. package/dist/components/cms/duplicate-document.d.ts +3 -2
  52. package/dist/components/cms/duplicate-document.js +5 -5
  53. package/dist/components/cms/duplicate-document.js.map +1 -1
  54. package/dist/components/cms/editable-cell.js +3 -6
  55. package/dist/components/cms/editable-cell.js.map +1 -1
  56. package/dist/components/cms/field-control.js +7 -6
  57. package/dist/components/cms/field-control.js.map +1 -1
  58. package/dist/components/cms/gate-validation.d.ts +27 -2
  59. package/dist/components/cms/gate-validation.js +19 -1
  60. package/dist/components/cms/gate-validation.js.map +1 -1
  61. package/dist/components/cms/publish-clean.d.ts +13 -6
  62. package/dist/components/cms/publish-clean.js +14 -6
  63. package/dist/components/cms/publish-clean.js.map +1 -1
  64. package/dist/components/cms/rich-text/editor.js +2 -2
  65. package/dist/components/cms/rich-text/editor.js.map +1 -1
  66. package/dist/components/cms/rich-text/insert.d.ts +7 -0
  67. package/dist/components/cms/rich-text/insert.js +7 -0
  68. package/dist/components/cms/rich-text/insert.js.map +1 -1
  69. package/dist/components/cms/use-publish-controller.js +3 -2
  70. package/dist/components/cms/use-publish-controller.js.map +1 -1
  71. package/dist/components/components/transfer-dialog.js +1 -0
  72. package/dist/components/components/transfer-dialog.js.map +1 -1
  73. package/dist/components/components/transfer.d.ts +10 -4
  74. package/dist/components/components/transfer.js +11 -9
  75. package/dist/components/components/transfer.js.map +1 -1
  76. package/dist/components/delivery-health-banner.d.ts +3 -2
  77. package/dist/components/delivery-health-banner.js +3 -2
  78. package/dist/components/delivery-health-banner.js.map +1 -1
  79. package/dist/components/nav-issues.d.ts +23 -3
  80. package/dist/components/nav-issues.js +38 -2
  81. package/dist/components/nav-issues.js.map +1 -1
  82. package/dist/components/redirects/redirects-collection-view-source.js +3 -2
  83. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  84. package/dist/components/section-nav.js +26 -2
  85. package/dist/components/section-nav.js.map +1 -1
  86. package/dist/components/users/users-collection-view-source.js +2 -1
  87. package/dist/components/users/users-collection-view-source.js.map +1 -1
  88. package/dist/lib/ai/apply-content.js +32 -28
  89. package/dist/lib/ai/apply-content.js.map +1 -1
  90. package/dist/lib/ai/tools.d.ts +10 -4
  91. package/dist/lib/ai/tools.js +12 -4
  92. package/dist/lib/ai/tools.js.map +1 -1
  93. package/dist/lib/ai/translate-document.js +11 -7
  94. package/dist/lib/ai/translate-document.js.map +1 -1
  95. package/dist/lib/ai/types.d.ts +9 -5
  96. package/dist/lib/ai/types.js.map +1 -1
  97. package/dist/lib/ai/update-fields.js +10 -29
  98. package/dist/lib/ai/update-fields.js.map +1 -1
  99. package/dist/lib/ai/vite.d.ts +22 -2
  100. package/dist/lib/ai/vite.js +12 -3
  101. package/dist/lib/ai/vite.js.map +1 -1
  102. package/dist/lib/cms/published-documents.d.ts +14 -3
  103. package/dist/lib/cms/published-documents.js +12 -3
  104. package/dist/lib/cms/published-documents.js.map +1 -1
  105. package/dist/lib/cms-views/view-model.d.ts +4 -4
  106. package/dist/lib/cms-views/view-model.js +16 -13
  107. package/dist/lib/cms-views/view-model.js.map +1 -1
  108. package/dist/lib/data/collection-content.d.ts +22 -1
  109. package/dist/lib/data/collection-content.js +51 -14
  110. package/dist/lib/data/collection-content.js.map +1 -1
  111. package/dist/lib/delivery/instant-publish.d.ts +0 -3
  112. package/dist/lib/delivery/instant-publish.js +0 -1
  113. package/dist/lib/delivery/instant-publish.js.map +1 -1
  114. package/dist/lib/engine-version.d.ts +3 -3
  115. package/dist/lib/engine-version.js +3 -3
  116. package/dist/lib/engine-version.js.map +1 -1
  117. package/dist/lib/i18n/messages.de.js +5 -0
  118. package/dist/lib/i18n/messages.de.js.map +1 -1
  119. package/dist/lib/i18n/messages.en.d.ts +4 -0
  120. package/dist/lib/i18n/messages.en.js +6 -0
  121. package/dist/lib/i18n/messages.en.js.map +1 -1
  122. package/dist/lib/locale/fallback.d.ts +5 -19
  123. package/dist/lib/locale/fallback.js +18 -39
  124. package/dist/lib/locale/fallback.js.map +1 -1
  125. package/dist/lib/locale/locale-store.js +2 -1
  126. package/dist/lib/locale/locale-store.js.map +1 -1
  127. package/dist/lib/operations/session-hydration.d.ts +24 -26
  128. package/dist/lib/operations/session-hydration.js +15 -53
  129. package/dist/lib/operations/session-hydration.js.map +1 -1
  130. package/dist/lib/operations/studio-operations.js +4 -6
  131. package/dist/lib/operations/studio-operations.js.map +1 -1
  132. package/dist/lib/persistence/backend-mode.d.ts +5 -25
  133. package/dist/lib/persistence/backend-mode.js +13 -30
  134. package/dist/lib/persistence/backend-mode.js.map +1 -1
  135. package/dist/lib/references/reference-model.d.ts +17 -3
  136. package/dist/lib/references/reference-model.js +17 -23
  137. package/dist/lib/references/reference-model.js.map +1 -1
  138. package/dist/lib/references/references-store.js +7 -0
  139. package/dist/lib/references/references-store.js.map +1 -1
  140. package/dist/lib/workspace/config-issues.d.ts +6 -0
  141. package/dist/lib/workspace/config-issues.js +8 -4
  142. package/dist/lib/workspace/config-issues.js.map +1 -1
  143. package/dist/lib/workspace/index.d.ts +1 -8
  144. package/dist/lib/workspace/index.js +1 -10
  145. package/dist/lib/workspace/index.js.map +1 -1
  146. package/dist/router/navigate-once.d.ts +19 -4
  147. package/dist/router/navigate-once.js +22 -5
  148. package/dist/router/navigate-once.js.map +1 -1
  149. package/package.json +3 -7
  150. package/dist/lib/webhooks/config.d.ts +0 -15
  151. package/dist/lib/webhooks/config.js +0 -65
  152. package/dist/lib/webhooks/config.js.map +0 -1
  153. package/dist/lib/webhooks/dispatcher.d.ts +0 -70
  154. package/dist/lib/webhooks/dispatcher.js +0 -61
  155. package/dist/lib/webhooks/dispatcher.js.map +0 -1
  156. package/dist/lib/webhooks/index.d.ts +0 -11
  157. package/dist/lib/webhooks/index.js +0 -10
  158. package/dist/lib/webhooks/index.js.map +0 -1
  159. package/dist/lib/webhooks/signing.d.ts +0 -15
  160. package/dist/lib/webhooks/signing.js +0 -24
  161. package/dist/lib/webhooks/signing.js.map +0 -1
  162. package/dist/lib/webhooks/tags.d.ts +0 -25
  163. package/dist/lib/webhooks/tags.js +0 -289
  164. package/dist/lib/webhooks/tags.js.map +0 -1
@@ -4,7 +4,9 @@ import { makeFunctionReference } from 'convex/server'
4
4
  import schema from './schema'
5
5
 
6
6
  /**
7
- * EC-579 (no-owner-call carve-out) — an asset edit must reach the live site.
7
+ * EC-579 — an asset edit must reach the live site (the wave-0089 carve-out; since
8
+ * the ruling the trigger sends the typed EVENT `{ kind: 'asset', id }`, and the host
9
+ * receiver answers it with `asset:<id>` + the project sweep).
8
10
  *
9
11
  * An asset row rides the delivery wire field by field (`delivery.ts`: url, alt,
10
12
  * dimensions, focal point, lqip, the `meta` bag), so editing one changes
@@ -31,8 +33,7 @@ const removeAsset = makeFunctionReference<'mutation'>('assets:remove')
31
33
  const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
32
34
 
33
35
  const SESSION_USER_ID = 'user_admin'
34
- const SCOPE = 'ec579-scope'
35
- const SWEEP = [`p:${SCOPE}:project`]
36
+ const ASSET_EVENT = { kind: 'asset', id: 'asset_ec579_1' }
36
37
 
37
38
  async function freshBackend() {
38
39
  const t = convexTest(schema, modules)
@@ -48,33 +49,33 @@ async function freshBackend() {
48
49
  return t
49
50
  }
50
51
 
51
- /** The scheduled `webhooks:deliver` calls, in order, as `{event, tags}`. */
52
- async function scheduledRevalidations(
53
- t: Awaited<ReturnType<typeof freshBackend>>,
54
- ): Promise<{ event: unknown; tags: unknown }[]> {
52
+ /** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
53
+ async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
55
54
  return t.run(async (ctx) => {
56
55
  const rows = await ctx.db.system.query('_scheduled_functions').collect()
57
56
  return rows
58
57
  .filter((row) => row.name.startsWith('webhooks'))
59
- .map((row) => {
60
- const [args] = row.args as [{ event?: unknown; tags?: unknown }]
61
- return { event: args?.event, tags: args?.tags }
62
- })
58
+ .map((row) => (row.args as [{ event?: unknown }])[0]?.event)
63
59
  })
64
60
  }
65
61
 
66
- 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
62
+ /** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
63
+ function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
64
+ const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
65
+ const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
66
+ const previous = keys.map((key) => env[key])
67
+ if (configured) {
68
+ env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
69
+ env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
70
+ } else {
71
+ for (const key of keys) delete env[key]
73
72
  }
74
73
  return run().finally(() => {
75
- if (!env) return
76
- if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
77
- else env['CANVAS_PROJECT_SCOPE'] = previous
74
+ keys.forEach((key, index) => {
75
+ const value = previous[index]
76
+ if (value === undefined) delete env[key]
77
+ else env[key] = value
78
+ })
78
79
  })
79
80
  }
80
81
 
@@ -94,7 +95,7 @@ const assetArgs = {
94
95
 
95
96
  describe('EC-579 — the write chokepoint revalidates on every asset edit', () => {
96
97
  it('an alt edit schedules a project sweep; the create before it schedules nothing', async () => {
97
- await withScope(SCOPE, async () => {
98
+ await withRevalidationEnv(true, async () => {
98
99
  const t = await freshBackend()
99
100
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
100
101
 
@@ -110,28 +111,24 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
110
111
  })
111
112
  expect(await scheduledRevalidations(t)).toHaveLength(1)
112
113
 
113
- // A brand-new asset is on no page yet, so its create invalidates nothing —
114
- // the same rule the browser dispatcher applies (`webhooks/tags.ts`).
114
+ // A brand-new asset is on no page yet, so its create invalidates nothing.
115
115
  await asAdmin.mutation(upsertAsset, assetArgs)
116
116
  expect(await scheduledRevalidations(t)).toHaveLength(1)
117
117
 
118
118
  await asAdmin.mutation(upsertAsset, { ...assetArgs, alt: 'new' })
119
119
 
120
120
  // The edit IS live output: the published snapshot already serves it.
121
- const snapshot = (await t.query(deliverySnapshot, { projectId: SCOPE })) as {
121
+ const snapshot = (await t.query(deliverySnapshot, { projectId: 'ec579' })) as {
122
122
  assets: { alt?: string }[]
123
123
  }
124
124
  expect(snapshot.assets[0]?.alt).toBe('new')
125
125
 
126
- expect(await scheduledRevalidations(t)).toEqual([
127
- { event: 'redirects.save', tags: SWEEP },
128
- { event: 'assets.upsert', tags: SWEEP },
129
- ])
126
+ expect(await scheduledRevalidations(t)).toEqual([{ kind: 'redirects' }, ASSET_EVENT])
130
127
  })
131
128
  })
132
129
 
133
130
  it('schedules a sweep on a focal-point edit, a meta edit, a re-upload, and a delete', async () => {
134
- await withScope(SCOPE, async () => {
131
+ await withRevalidationEnv(true, async () => {
135
132
  const t = await freshBackend()
136
133
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
137
134
 
@@ -147,20 +144,16 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
147
144
  })
148
145
  await asAdmin.mutation(removeAsset, { assetId: assetArgs.assetId })
149
146
 
150
- // 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
- ])
147
+ // One event per edit and per delete — the same event for both: the receiver
148
+ // derives `asset:<id>` + the sweep either way (a page reading the asset through
149
+ // a document asset FIELD carries no `asset:` tag — `next/src/route-core.ts`
150
+ // `scopeAssetsToPage`, EC-589).
151
+ expect(await scheduledRevalidations(t)).toEqual([ASSET_EVENT, ASSET_EVENT, ASSET_EVENT, ASSET_EVENT])
159
152
  })
160
153
  })
161
154
 
162
155
  it('removing an asset that does not exist writes nothing and schedules nothing', async () => {
163
- await withScope(SCOPE, async () => {
156
+ await withRevalidationEnv(true, async () => {
164
157
  const t = await freshBackend()
165
158
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
166
159
  await asAdmin.mutation(removeAsset, { assetId: 'asset_never_existed' })
@@ -168,8 +161,8 @@ describe('EC-579 — the write chokepoint revalidates on every asset edit', () =
168
161
  })
169
162
  })
170
163
 
171
- it('stays a silent no-op on a deployment with no CANVAS_PROJECT_SCOPE', async () => {
172
- await withScope(undefined, async () => {
164
+ it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
165
+ await withRevalidationEnv(false, async () => {
173
166
  const t = await freshBackend()
174
167
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
175
168
  await asAdmin.mutation(upsertAsset, assetArgs)
@@ -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,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import schema from './schema'
5
+
6
+ /**
7
+ * EC-579 — the write chokepoint is the ONE revalidation emitter, and it sends the
8
+ * EVENT, not tags.
9
+ *
10
+ * The REAL `cms:*` mutations through the REAL `functions.ts` trigger wrapper on
11
+ * `convex-test`'s in-memory backend, asserting on the `_scheduled_functions` system
12
+ * table: what `scheduleRevalidate` hands the `webhooks:deliver` action. The host
13
+ * receiver derives the tags from the event with its own scope and locales
14
+ * (`@elytracms/next` `revalidationTags`), so nothing here knows a tag format or a
15
+ * scope — the event carries only what changed.
16
+ *
17
+ * Documents reach the live wire only through their published pin (`delivery.ts`),
18
+ * so only a pin move or the delete of a pinned row is an event; a draft save, a
19
+ * version snapshot and the delete of a never-published row are not.
20
+ */
21
+
22
+ const modules = {
23
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
24
+ './_generated/api.ts': async () => ({}),
25
+ }
26
+
27
+ const upsertDocument = makeFunctionReference<'mutation'>('cms:upsertDocument')
28
+ const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
29
+ const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
30
+ const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
31
+ const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
32
+ const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
33
+
34
+ const SESSION_USER_ID = 'user_admin'
35
+ const STAMP = '2026-10-25T00:00:00.000Z'
36
+
37
+ async function freshBackend() {
38
+ const t = convexTest(schema, modules)
39
+ await t.run(async (ctx) => {
40
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: STAMP, updatedAt: STAMP })
41
+ })
42
+ return { t, asAdmin: t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' }) }
43
+ }
44
+
45
+ type Backend = Awaited<ReturnType<typeof freshBackend>>
46
+
47
+ /** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
48
+ async function scheduledEvents({ t }: Backend): Promise<unknown[]> {
49
+ return t.run(async (ctx) => {
50
+ const rows = await ctx.db.system.query('_scheduled_functions').collect()
51
+ return rows
52
+ .filter((row) => row.name.startsWith('webhooks'))
53
+ .map((row) => (row.args as [{ event?: unknown }])[0]?.event)
54
+ })
55
+ }
56
+
57
+ /** The Convex gate: the endpoint + secret pair the `deliver` action posts with. */
58
+ function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
59
+ const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
60
+ const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
61
+ const previous = keys.map((key) => env[key])
62
+ if (configured) {
63
+ env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
64
+ env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
65
+ } else {
66
+ for (const key of keys) delete env[key]
67
+ }
68
+ return run().finally(() => {
69
+ keys.forEach((key, index) => {
70
+ const value = previous[index]
71
+ if (value === undefined) delete env[key]
72
+ else env[key] = value
73
+ })
74
+ })
75
+ }
76
+
77
+ function doc(collection: string, id: string, values: Record<string, unknown>) {
78
+ return { collection, id, values }
79
+ }
80
+
81
+ async function saveDraft({ asAdmin }: Backend, collection: string, id: string, values: Record<string, unknown>) {
82
+ await asAdmin.mutation(upsertDocument, {
83
+ collection,
84
+ docId: id,
85
+ document: doc(collection, id, values),
86
+ createdAt: STAMP,
87
+ updatedAt: STAMP,
88
+ })
89
+ }
90
+
91
+ async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
92
+ const version = (await backend.asAdmin.mutation(saveVersion, {
93
+ collection,
94
+ docId: id,
95
+ snapshot: doc(collection, id, values),
96
+ createdAt: STAMP,
97
+ })) as { version: number }
98
+ await backend.asAdmin.mutation(publishVersion, {
99
+ collection,
100
+ docId: id,
101
+ version: version.version,
102
+ updatedAt: STAMP,
103
+ publishedAt: STAMP,
104
+ })
105
+ }
106
+
107
+ describe('EC-579 — documents: one event per change to the live pin', () => {
108
+ it('publish, republish, unpublish and delete each schedule exactly one event with collection + id', async () => {
109
+ await withRevalidationEnv(true, async () => {
110
+ const backend = await freshBackend()
111
+ await saveDraft(backend, 'post', 'hello', { title: 'Draft' })
112
+ // A draft save is not live output: nothing scheduled.
113
+ expect(await scheduledEvents(backend)).toEqual([])
114
+
115
+ await publish(backend, 'post', 'hello', { title: 'v1' })
116
+ await publish(backend, 'post', 'hello', { title: 'v2' })
117
+ await backend.asAdmin.mutation(unpublishDocument, { collection: 'post', docId: 'hello', updatedAt: STAMP })
118
+ await publish(backend, 'post', 'hello', { title: 'v3' })
119
+ await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'hello' })
120
+
121
+ expect(await scheduledEvents(backend)).toEqual([
122
+ { kind: 'publish', collection: 'post', id: 'hello' },
123
+ { kind: 'publish', collection: 'post', id: 'hello' },
124
+ { kind: 'unpublish', collection: 'post', id: 'hello' },
125
+ { kind: 'publish', collection: 'post', id: 'hello' },
126
+ { kind: 'delete', collection: 'post', id: 'hello' },
127
+ ])
128
+ })
129
+ })
130
+
131
+ it('a draft save on a PUBLISHED document, and the delete of a never-published one, schedule nothing', async () => {
132
+ await withRevalidationEnv(true, async () => {
133
+ const backend = await freshBackend()
134
+ await saveDraft(backend, 'post', 'live', { title: 'v1' })
135
+ await publish(backend, 'post', 'live', { title: 'v1' })
136
+ // Known-positive control: this instrument sees the publish.
137
+ expect(await scheduledEvents(backend)).toHaveLength(1)
138
+
139
+ await saveDraft(backend, 'post', 'live', { title: 'edited draft' })
140
+ await saveDraft(backend, 'post', 'never', { title: 'draft only' })
141
+ await backend.asAdmin.mutation(deleteDocument, { collection: 'post', docId: 'never' })
142
+ expect(await scheduledEvents(backend)).toHaveLength(1)
143
+ })
144
+ })
145
+
146
+ /**
147
+ * The wave-0089 finding (EC-579 Done § Found-but-left 4): a draft write to a
148
+ * `cms-data-sources` record changed the public snapshot and no revalidation fired.
149
+ * EC-574 deleted the snapshot's `sources` strand; this pins that it stays closed —
150
+ * the draft row is not on the published wire, so silence is correct.
151
+ */
152
+ it('a cms-data-sources draft write changes no published output and schedules nothing', async () => {
153
+ await withRevalidationEnv(true, async () => {
154
+ const backend = await freshBackend()
155
+ const before = await backend.t.query(deliverySnapshot, { projectId: 'p' })
156
+ await saveDraft(backend, 'cms-data-sources', 'source-1', { title: 'A data source' })
157
+ const after = await backend.t.query(deliverySnapshot, { projectId: 'p' })
158
+ expect(after).toEqual(before)
159
+ expect(await scheduledEvents(backend)).toEqual([])
160
+ })
161
+ })
162
+
163
+ it('stays a silent no-op on a deployment without the endpoint/secret pair — the write still lands', async () => {
164
+ await withRevalidationEnv(false, async () => {
165
+ const backend = await freshBackend()
166
+ await saveDraft(backend, 'post', 'hello', { title: 'v1' })
167
+ await publish(backend, 'post', 'hello', { title: 'v1' })
168
+ // The first test is the known-positive control for the same publish.
169
+ expect(await scheduledEvents(backend)).toEqual([])
170
+ expect(
171
+ await backend.t.run(async (ctx) => (await ctx.db.query('cmsDocuments').first())?.publishedVersion),
172
+ ).toBe(1)
173
+ })
174
+ })
175
+ })
@@ -4,12 +4,12 @@ import { makeFunctionReference } from 'convex/server'
4
4
  import schema from './schema'
5
5
 
6
6
  /**
7
- * EC-510 — the Convex half of "a redirect change must reach the live site".
7
+ * EC-510 — "a redirect change must reach the live site".
8
8
  *
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.
9
+ * The EC-286 write chokepoint is the ONE emitter (EC-579 deleted the studio's
10
+ * browser dispatcher), and it catches writers the studio does not own. Before
11
+ * EC-510 a redirect write reached the host by NO path, while a document publish
12
+ * reached it by two.
13
13
  *
14
14
  * This runs the REAL `redirects.save`/`redirects.remove` mutations through the
15
15
  * REAL `functions.ts` trigger wrapper on `convex-test`'s in-memory backend, and
@@ -17,10 +17,10 @@ import schema from './schema'
17
17
  * `scheduleRevalidate` actually produces (a mutation cannot `fetch`, so it hands
18
18
  * the signed POST to the `webhooks:deliver` action).
19
19
  *
20
- * The 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.
20
+ * The endpoint/secret pair is the gate `scheduleRevalidate` reads: an
21
+ * unconfigured deployment must stay a silent no-op, because publishing may never
22
+ * depend on a reachable host. Both sides are pinned here so neither can regress
23
+ * into the other.
24
24
  */
25
25
 
26
26
  const modules = {
@@ -34,7 +34,6 @@ const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
34
34
  const removeRedirect = makeFunctionReference<'mutation'>('redirects:remove')
35
35
 
36
36
  const SESSION_USER_ID = 'user_admin'
37
- const SCOPE = 'ec510-scope'
38
37
 
39
38
  async function freshBackend() {
40
39
  const t = convexTest(schema, modules)
@@ -50,33 +49,33 @@ async function freshBackend() {
50
49
  return t
51
50
  }
52
51
 
53
- /** The scheduled `webhooks:deliver` calls, in order, as `{event, tags}`. */
54
- async function scheduledRevalidations(
55
- t: Awaited<ReturnType<typeof freshBackend>>,
56
- ): Promise<{ event: unknown; tags: unknown }[]> {
52
+ /** The scheduled `webhooks:deliver` calls, in order — the event each one carries. */
53
+ async function scheduledRevalidations(t: Awaited<ReturnType<typeof freshBackend>>): Promise<unknown[]> {
57
54
  return t.run(async (ctx) => {
58
55
  const rows = await ctx.db.system.query('_scheduled_functions').collect()
59
56
  return rows
60
57
  .filter((row) => row.name.startsWith('webhooks'))
61
- .map((row) => {
62
- const [args] = row.args as [{ event?: unknown; tags?: unknown }]
63
- return { event: args?.event, tags: args?.tags }
64
- })
58
+ .map((row) => (row.args as [{ event?: unknown }])[0]?.event)
65
59
  })
66
60
  }
67
61
 
68
- 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
62
+ /** The Convex gate (EC-579): the endpoint + secret pair the `deliver` action posts with. */
63
+ function withRevalidationEnv<T>(configured: boolean, run: () => Promise<T>): Promise<T> {
64
+ const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
65
+ const keys = ['CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET'] as const
66
+ const previous = keys.map((key) => env[key])
67
+ if (configured) {
68
+ env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
69
+ env['CANVAS_REVALIDATE_SECRET'] = 'ec579-secret'
70
+ } else {
71
+ for (const key of keys) delete env[key]
75
72
  }
76
73
  return run().finally(() => {
77
- if (!env) return
78
- if (previous === undefined) delete env['CANVAS_PROJECT_SCOPE']
79
- else env['CANVAS_PROJECT_SCOPE'] = previous
74
+ keys.forEach((key, index) => {
75
+ const value = previous[index]
76
+ if (value === undefined) delete env[key]
77
+ else env[key] = value
78
+ })
80
79
  })
81
80
  }
82
81
 
@@ -91,8 +90,8 @@ const redirectArgs = {
91
90
  }
92
91
 
93
92
  describe('EC-510 — the write chokepoint revalidates on every redirect write', () => {
94
- it('schedules a project sweep on create, retarget, disable, and delete', async () => {
95
- await withScope(SCOPE, async () => {
93
+ it('schedules one `redirects` event on create, retarget, disable, and delete', async () => {
94
+ await withRevalidationEnv(true, async () => {
96
95
  const t = await freshBackend()
97
96
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
98
97
 
@@ -103,17 +102,18 @@ describe('EC-510 — the write chokepoint revalidates on every redirect write',
103
102
  await asAdmin.mutation(saveRedirect, { ...redirectArgs, enabled: false })
104
103
  await asAdmin.mutation(removeRedirect, { redirectId: redirectArgs.redirectId })
105
104
 
105
+ // The receiver answers each with the project sweep (`revalidationTags`).
106
106
  expect(await scheduledRevalidations(t)).toEqual([
107
- { 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`] },
107
+ { kind: 'redirects' },
108
+ { kind: 'redirects' },
109
+ { kind: 'redirects' },
110
+ { kind: 'redirects' },
111
111
  ])
112
112
  })
113
113
  })
114
114
 
115
- it('stays a silent no-op on a deployment with no CANVAS_PROJECT_SCOPE', async () => {
116
- await withScope(undefined, async () => {
115
+ it('stays a silent no-op on a deployment without the endpoint/secret pair', async () => {
116
+ await withRevalidationEnv(false, async () => {
117
117
  const t = await freshBackend()
118
118
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
119
119
  await asAdmin.mutation(saveRedirect, redirectArgs)
@@ -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
  }