@elytracms/studio 0.0.86 → 0.0.87

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