@elytracms/studio 0.0.94 → 0.0.96

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-alt-migration.test.ts +2 -2
  2. package/convex/asset-meta-patch.test.ts +218 -0
  3. package/convex/asset-replace-delete-race.test.ts +127 -0
  4. package/convex/assets.ts +27 -2
  5. package/convex/cms.ts +124 -8
  6. package/convex/delivery-redirects.test.ts +167 -0
  7. package/convex/delivery.ts +15 -5
  8. package/convex/rail-write-parity.test.ts +6 -6
  9. package/convex/redirects.ts +1 -1
  10. package/convex/schema.ts +6 -0
  11. package/convex/strict-reference-race.test.ts +139 -0
  12. package/dist/addon.d.ts +1 -1
  13. package/dist/addon.js.map +1 -1
  14. package/dist/components/ai/ai-chat-panel.js +15 -2
  15. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  16. package/dist/components/ai/translate-document-action.d.ts +6 -0
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  19. package/dist/components/ai/translate-document-dialog.js +5 -5
  20. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  21. package/dist/components/assets/asset-detail.js +29 -11
  22. package/dist/components/assets/asset-detail.js.map +1 -1
  23. package/dist/components/assets/asset-locale.d.ts +16 -0
  24. package/dist/components/assets/asset-locale.js +30 -0
  25. package/dist/components/assets/asset-locale.js.map +1 -0
  26. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  27. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  29. package/dist/components/assets/asset-meta-label.d.ts +13 -0
  30. package/dist/components/assets/asset-meta-label.js +17 -0
  31. package/dist/components/assets/asset-meta-label.js.map +1 -0
  32. package/dist/components/assets/asset-metadata.d.ts +89 -15
  33. package/dist/components/assets/asset-metadata.js +123 -11
  34. package/dist/components/assets/asset-metadata.js.map +1 -1
  35. package/dist/components/assets/asset-search.d.ts +6 -2
  36. package/dist/components/assets/asset-search.js +19 -3
  37. package/dist/components/assets/asset-search.js.map +1 -1
  38. package/dist/components/assets/asset-thumb.js +6 -2
  39. package/dist/components/assets/asset-thumb.js.map +1 -1
  40. package/dist/components/assets/assets-collection-view-source.d.ts +25 -9
  41. package/dist/components/assets/assets-collection-view-source.js +80 -26
  42. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  43. package/dist/components/assets/assets-store.js +17 -4
  44. package/dist/components/assets/assets-store.js.map +1 -1
  45. package/dist/components/builder/builder-store.d.ts +21 -0
  46. package/dist/components/builder/builder-store.js +22 -0
  47. package/dist/components/builder/builder-store.js.map +1 -1
  48. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  49. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  50. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  51. package/dist/components/builder/canvas-rebase.d.ts +101 -0
  52. package/dist/components/builder/canvas-rebase.js +179 -0
  53. package/dist/components/builder/canvas-rebase.js.map +1 -0
  54. package/dist/components/builder/canvas-render-context.d.ts +6 -3
  55. package/dist/components/builder/canvas-render-context.js +17 -8
  56. package/dist/components/builder/canvas-render-context.js.map +1 -1
  57. package/dist/components/builder/composition-field-editor.d.ts +26 -2
  58. package/dist/components/builder/composition-field-editor.js +46 -27
  59. package/dist/components/builder/composition-field-editor.js.map +1 -1
  60. package/dist/components/builder/node-factory.js +15 -3
  61. package/dist/components/builder/node-factory.js.map +1 -1
  62. package/dist/components/builder/property-inspector.js +3 -1
  63. package/dist/components/builder/property-inspector.js.map +1 -1
  64. package/dist/components/builder/run-inspector.js +10 -6
  65. package/dist/components/builder/run-inspector.js.map +1 -1
  66. package/dist/components/cms/asset-field.d.ts +5 -2
  67. package/dist/components/cms/asset-field.js +34 -17
  68. package/dist/components/cms/asset-field.js.map +1 -1
  69. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  70. package/dist/components/cms/asset-picker-core.js +2 -2
  71. package/dist/components/cms/asset-picker-core.js.map +1 -1
  72. package/dist/components/cms/asset-picker.js +6 -2
  73. package/dist/components/cms/asset-picker.js.map +1 -1
  74. package/dist/components/cms/canvas-commit.d.ts +62 -0
  75. package/dist/components/cms/canvas-commit.js +39 -0
  76. package/dist/components/cms/canvas-commit.js.map +1 -0
  77. package/dist/components/cms/cms-store.d.ts +21 -8
  78. package/dist/components/cms/cms-store.js +19 -10
  79. package/dist/components/cms/cms-store.js.map +1 -1
  80. package/dist/components/cms/collection-table.js +27 -18
  81. package/dist/components/cms/collection-table.js.map +1 -1
  82. package/dist/components/cms/commit-policy.d.ts +6 -2
  83. package/dist/components/cms/commit-policy.js +1 -1
  84. package/dist/components/cms/commit-policy.js.map +1 -1
  85. package/dist/components/cms/detail-editor.js +12 -2
  86. package/dist/components/cms/detail-editor.js.map +1 -1
  87. package/dist/components/cms/draft-commit.d.ts +16 -3
  88. package/dist/components/cms/draft-commit.js +3 -2
  89. package/dist/components/cms/draft-commit.js.map +1 -1
  90. package/dist/components/cms/use-document-draft.d.ts +121 -5
  91. package/dist/components/cms/use-document-draft.js +197 -54
  92. package/dist/components/cms/use-document-draft.js.map +1 -1
  93. package/dist/components/cms/use-live-url.d.ts +6 -1
  94. package/dist/components/cms/use-live-url.js +12 -9
  95. package/dist/components/cms/use-live-url.js.map +1 -1
  96. package/dist/components/collection-view/cms-collection-view-source.js +11 -5
  97. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  98. package/dist/components/components/transfer-dialog.js +6 -4
  99. package/dist/components/components/transfer-dialog.js.map +1 -1
  100. package/dist/components/deployment-setup-gate.js +10 -1
  101. package/dist/components/deployment-setup-gate.js.map +1 -1
  102. package/dist/components/records/record-lens.js +3 -8
  103. package/dist/components/records/record-lens.js.map +1 -1
  104. package/dist/lib/ai/chat-state.d.ts +7 -0
  105. package/dist/lib/ai/chat-state.js.map +1 -1
  106. package/dist/lib/ai/tools.d.ts +8 -0
  107. package/dist/lib/ai/tools.js +4 -0
  108. package/dist/lib/ai/tools.js.map +1 -1
  109. package/dist/lib/assets/upload.d.ts +25 -7
  110. package/dist/lib/assets/upload.js +30 -21
  111. package/dist/lib/assets/upload.js.map +1 -1
  112. package/dist/lib/cms/published-documents.d.ts +24 -6
  113. package/dist/lib/cms/published-documents.js +39 -6
  114. package/dist/lib/cms/published-documents.js.map +1 -1
  115. package/dist/lib/cms-views/view-model.d.ts +25 -105
  116. package/dist/lib/cms-views/view-model.js +36 -103
  117. package/dist/lib/cms-views/view-model.js.map +1 -1
  118. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  119. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  120. package/dist/lib/collection-view/collection-view-source.d.ts +21 -8
  121. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  122. package/dist/lib/data/collection-content.js +5 -0
  123. package/dist/lib/data/collection-content.js.map +1 -1
  124. package/dist/lib/engine-version.d.ts +1 -1
  125. package/dist/lib/engine-version.js +1 -1
  126. package/dist/lib/engine-version.js.map +1 -1
  127. package/dist/lib/i18n/messages.de.js +20 -4
  128. package/dist/lib/i18n/messages.de.js.map +1 -1
  129. package/dist/lib/i18n/messages.en.d.ts +16 -1
  130. package/dist/lib/i18n/messages.en.js +22 -5
  131. package/dist/lib/i18n/messages.en.js.map +1 -1
  132. package/dist/lib/operations/cms-persistence.d.ts +80 -35
  133. package/dist/lib/operations/cms-persistence.js +348 -85
  134. package/dist/lib/operations/cms-persistence.js.map +1 -1
  135. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  136. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  137. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  138. package/dist/lib/persistence/convex-adapter.js +54 -8
  139. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  140. package/dist/lib/persistence/deployment-readiness.js +14 -11
  141. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  142. package/dist/lib/persona/labels.js +2 -0
  143. package/dist/lib/persona/labels.js.map +1 -1
  144. package/dist/lib/query/index.d.ts +2 -2
  145. package/dist/lib/query/index.js +1 -1
  146. package/dist/lib/query/index.js.map +1 -1
  147. package/dist/lib/query/operations-query.d.ts +26 -0
  148. package/dist/lib/query/operations-query.js +12 -0
  149. package/dist/lib/query/operations-query.js.map +1 -1
  150. package/dist/lib/registry/index.js +2 -2
  151. package/dist/lib/registry/index.js.map +1 -1
  152. package/dist/lib/registry/preview.d.ts +10 -0
  153. package/dist/lib/registry/preview.js +13 -4
  154. package/dist/lib/registry/preview.js.map +1 -1
  155. package/dist/lib/routes/route-issues.js +6 -3
  156. package/dist/lib/routes/route-issues.js.map +1 -1
  157. package/dist/lib/workspace/workspace-source.js +6 -7
  158. package/dist/lib/workspace/workspace-source.js.map +1 -1
  159. package/dist/previews/entry-node.d.ts +14 -0
  160. package/dist/previews/entry-node.js +22 -0
  161. package/dist/previews/entry-node.js.map +1 -0
  162. package/dist/previews/harness.js +2 -16
  163. package/dist/previews/harness.js.map +1 -1
  164. package/package.json +3 -3
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import type { ConvexHttpClient } from 'convex/browser'
6
+ // The host's two readers, from their own modules — the package index also loads the React
7
+ // delivery kit, which the edge-runtime environment cannot evaluate.
8
+ import { assembleLiveSource } from '../../next/src/host-content'
9
+ import { createContentSnapshot } from '../../next/src/snapshot'
10
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
11
+ import schema from './schema'
12
+
13
+ /**
14
+ * The delivery snapshot's redirect strand, end to end: rows stored in the deployment's
15
+ * `records` table (kind `redirect`) → the REAL `delivery:snapshot` query (convex-test) →
16
+ * the REAL host loader (`assembleLiveSource`, `@elytracms/next`).
17
+ *
18
+ * EC-628 (Codex F04, `tasks/codex-review-2026-10-27/backend.convex.test.ts` "F04 upstream"):
19
+ * the record store's open data bag accepts drift the normal CRUD refuses (a dashboard edit,
20
+ * an older writer). Before, an enabled row with `to: 42` was SERVED by the snapshot and
21
+ * vanished in the host with zero issues, and a switch that is not a boolean was decided by
22
+ * truthiness (`'false'` served, a missing switch silently paused). Now the snapshot reads
23
+ * every stored redirect through the one rule (`readLiveRedirects`): what it cannot read is
24
+ * withheld and reported on the snapshot's `redirectIssues` strand, which the host carries
25
+ * into its `sourceIssues` and onto the 404 at the redirect's `from`.
26
+ */
27
+
28
+ const modules = {
29
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
30
+ './_generated/api.ts': async () => ({}),
31
+ }
32
+ const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
33
+
34
+ const AT = '2026-10-27T12:00:00.000Z'
35
+ const LOCALES = { default: 'en', locales: ['en'] }
36
+
37
+ /** Insert redirect rows as the record store holds them — raw, as drift reaches it. */
38
+ async function deploymentWith(rows: ReadonlyArray<{ id: string; data: Record<string, unknown> }>) {
39
+ const t = convexTest(schema, modules)
40
+ await t.run(async (ctx) => {
41
+ for (const row of rows) {
42
+ await ctx.db.insert('records', { kind: 'redirect', recordId: row.id, data: row.data, at: AT, createdAt: AT, updatedAt: AT })
43
+ }
44
+ })
45
+ return t
46
+ }
47
+
48
+ const redirect = (id: string, data: Record<string, unknown> = {}) => ({
49
+ id,
50
+ data: { from: `/${id}`, to: '/target', permanent: true, enabled: true, ...data },
51
+ })
52
+
53
+ describe('EC-628 — the snapshot names every stored redirect it cannot serve', () => {
54
+ it('drift is withheld and reported upstream, then located by the host (F04 + disabled-state drift)', async () => {
55
+ const t = await deploymentWith([
56
+ redirect('good'),
57
+ redirect('bad', { to: 42 }), // F04's row
58
+ redirect('paused', { enabled: false }), // a paused redirect: not live, not a finding
59
+ redirect('string-off', { enabled: 'false' }), // truthy: was SERVED before
60
+ { id: 'no-switch', data: { from: '/no-switch', to: '/target', permanent: true } }, // falsy: was silently paused
61
+ ])
62
+ const payload = (await t.query(snapshot, { projectId: 'site' })) as {
63
+ redirects: unknown[]
64
+ redirectIssues: Array<{ code: string; path: unknown[] }>
65
+ }
66
+ expect(payload.redirects).toEqual([{ id: 'good', from: '/good', to: '/target', permanent: true }])
67
+ expect(payload.redirectIssues.map((issue) => [issue.code, issue.path])).toEqual([
68
+ ['invalid-redirect', ['redirects', 'bad']],
69
+ ['invalid-redirect', ['redirects', 'no-switch']],
70
+ ['invalid-redirect', ['redirects', 'string-off']],
71
+ ])
72
+
73
+ const client = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published').client({
74
+ perspective: 'published',
75
+ })
76
+ // Control: the readable redirect traverses the whole pipeline, with no issue.
77
+ expect(client.resolvePage('/good').status).toBe('redirect')
78
+ expect(client.resolvePage('/good').issues).toEqual([])
79
+ expect(client.sourceIssues.map((issue) => issue.path[1])).toEqual(['bad', 'no-switch', 'string-off'])
80
+ for (const path of ['/bad', '/string-off', '/no-switch']) {
81
+ const result = client.resolvePage(path)
82
+ expect(result.status).toBe('notFound')
83
+ expect(result.issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
84
+ }
85
+ // The paused redirect is a 404 with nothing to say — pausing is not drift.
86
+ expect(client.resolvePage('/paused').status).toBe('notFound')
87
+ expect(client.resolvePage('/paused').issues).toEqual([])
88
+ })
89
+ })
90
+
91
+ /**
92
+ * EC-627 — "which redirects are live" is decided by ONE reader on every side. The same
93
+ * stored set is read (1) by the Convex delivery snapshot (`delivery:snapshot`, through
94
+ * `readLiveRedirects`) and assembled by the host's live loader, and (2) by the host's OTHER
95
+ * reader, `createContentSnapshot` (`next/src/snapshot.ts`, `readLiveRedirects` over
96
+ * `adapter.records(redirectRecordKind).list`) — through the REAL `ConvexPersistenceAdapter`
97
+ * over the same convex-test deployment, so both sides read the very same stored rows.
98
+ *
99
+ * The set holds what a hand-built copy of the rule gets wrong: a paused redirect (a copy that
100
+ * forgets the switch serves it), a switch that is not a boolean (a truthy copy serves
101
+ * `'false'`), a duplicate `from` (`/old` and `/old/` — the lower id applies on both sides)
102
+ * and an editorial `meta` bag (never on the routing record). Measured: replacing either
103
+ * side's call with a hand-built `.filter((r) => r.enabled)` projection turns this red.
104
+ */
105
+ describe('EC-627 — the Convex snapshot and the host adapter snapshot serve one live set', () => {
106
+ const STORED = [
107
+ redirect('a', { meta: { note: 'editorial only' } }),
108
+ redirect('paused', { enabled: false }),
109
+ redirect('string-off', { enabled: 'false' }),
110
+ { id: 'dup-1', data: { from: '/old', to: '/one', permanent: true, enabled: true } },
111
+ { id: 'dup-2', data: { from: '/old/', to: '/two', permanent: false, enabled: true } },
112
+ ]
113
+ const PATHS = ['/a', '/paused', '/string-off', '/old', '/old/']
114
+
115
+ /** The decision a reader makes per URL: what a visitor gets. */
116
+ const outcomes = (client: { resolvePage(url: string): { status: string; redirect: unknown } }) =>
117
+ PATHS.map((path) => {
118
+ const result = client.resolvePage(path)
119
+ return { path, status: result.status, redirect: result.redirect }
120
+ })
121
+
122
+ it('the same stored set yields the same live set through both readers', async () => {
123
+ const t = await deploymentWith(STORED)
124
+ await t.run(async (ctx) => {
125
+ await ctx.db.insert('members', { userId: 'admin', role: 'admin', createdAt: AT, updatedAt: AT })
126
+ })
127
+
128
+ // (1) the deployment's public snapshot → the host's live loader
129
+ const payload = await t.query(snapshot, { projectId: 'site' })
130
+ const viaSnapshot = assembleLiveSource(payload as never, { locales: LOCALES, collections: [], routes: [] }, 'published')
131
+ .client({ perspective: 'published' })
132
+
133
+ // (2) the host's adapter reader over the same deployment
134
+ const asAdmin = t.withIdentity({ subject: 'admin', issuer: 'https://convex.test' })
135
+ const adapter = new ConvexPersistenceAdapter(
136
+ {
137
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
138
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin.mutation(ref, args ?? {}),
139
+ } as unknown as ConvexHttpClient,
140
+ 'site',
141
+ )
142
+ const viaAdapter = (await createContentSnapshot(adapter, 'site', { locales: LOCALES, collections: [], routes: [] }))
143
+ .client({ perspective: 'published' })
144
+
145
+ // The instrument can tell outcomes apart — pinned explicitly, not only compared.
146
+ expect(outcomes(viaSnapshot)).toEqual([
147
+ { path: '/a', status: 'redirect', redirect: { target: '/target', permanent: true } },
148
+ { path: '/paused', status: 'notFound', redirect: null },
149
+ { path: '/string-off', status: 'notFound', redirect: null },
150
+ { path: '/old', status: 'redirect', redirect: { target: '/one', permanent: true } },
151
+ { path: '/old/', status: 'redirect', redirect: { target: '/one', permanent: true } },
152
+ ])
153
+ expect(outcomes(viaAdapter)).toEqual(outcomes(viaSnapshot))
154
+ // The duplicate is one issue, the same on both sides (the router's one redirect set).
155
+ const duplicates = (issues: readonly { code: string; path: readonly unknown[] }[]) =>
156
+ issues.filter((issue) => issue.code === 'invalid-redirect').map((issue) => issue.path)
157
+ expect(duplicates(viaSnapshot.routerIssues)).toEqual([['redirects', 'dup-2']])
158
+ expect(duplicates(viaAdapter.routerIssues)).toEqual(duplicates(viaSnapshot.routerIssues))
159
+
160
+ // EC-628 (the adapter snapshot): the drifted row is NAMED on both sides, not only withheld —
161
+ // on the source and on the 404 at its `from`. (Before, the adapter path withheld it silently.)
162
+ const sourcePaths = (client: typeof viaSnapshot) => client.sourceIssues.map((issue) => [issue.code, issue.path])
163
+ expect(sourcePaths(viaSnapshot)).toEqual([['invalid-redirect', ['redirects', 'string-off']]])
164
+ expect(sourcePaths(viaAdapter)).toEqual(sourcePaths(viaSnapshot))
165
+ expect(viaAdapter.resolvePage('/string-off').issues.map((issue) => issue.code)).toEqual(['invalid-redirect'])
166
+ })
167
+ })
@@ -1,5 +1,5 @@
1
1
  import { v } from 'convex/values'
2
- import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, liveRedirectRecords, unfoldValue, variantOf } from '@elytracms/core/cms-core'
2
+ import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, readLiveRedirects, unfoldValue, variantOf } from '@elytracms/core/cms-core'
3
3
  import type { Perspective } from '@elytracms/core/cms-core'
4
4
  import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
5
5
  import { constantTimeEquals } from '@elytracms/core/revalidation'
@@ -77,6 +77,12 @@ interface DeliverySnapshot {
77
77
  * infra redirects stay native (`next.config`/`vercel`), evaluated before these.
78
78
  */
79
79
  redirects: unknown[]
80
+ /**
81
+ * EC-628 — one `invalid-redirect` issue per stored redirect the live rule withheld because
82
+ * it could not read it (id, `from` and the kind of fault — never the target or a value).
83
+ * Paused redirects are not here: pausing is not drift.
84
+ */
85
+ redirectIssues: unknown[]
80
86
  }
81
87
 
82
88
  /**
@@ -161,16 +167,20 @@ export async function loadSnapshot(
161
167
  // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
162
168
  // stored but never live, in either perspective. EC-616: read through the redirect
163
169
  // kind's one server-side reader (records + the marked legacy tolerant read) and
164
- // projected by the ONE enabled-redirect reader, `liveRedirectRecords` (EC-590 / EC-627 —
165
- // this was the last hand-built copy on Convex).
166
- const redirects = liveRedirectRecords(await loadRedirectEntries(ctx, projectId))
170
+ // decided by the ONE live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
171
+ // was the last hand-built copy on Convex). EC-628: a stored redirect the rule cannot
172
+ // read (a non-boolean switch, a non-string `to`) is withheld AND reported — the host
173
+ // carries `redirectIssues` into its source issues — instead of being served or dropped
174
+ // by truthiness. The record kind's own read, not the project schema (EC-585 1e).
175
+ const live = readLiveRedirects(await loadRedirectEntries(ctx, projectId))
167
176
 
168
177
  return {
169
178
  projectId,
170
179
  perspective,
171
180
  documents,
172
181
  assets,
173
- redirects,
182
+ redirects: live.redirects,
183
+ redirectIssues: live.issues,
174
184
  }
175
185
  }
176
186
 
@@ -70,19 +70,19 @@ async function seedHero(adapter: PersistenceAdapter) {
70
70
 
71
71
  /**
72
72
  * "Reset to centre", then clear alt — the two connected clears that never persisted.
73
- * EC-587: the alt is cleared the way the options dialog clears it, by writing the bag
74
- * without it (`meta.alt` is its one home).
73
+ * EC-587: the alt is cleared the way the options dialog clears it — since EC-634 the
74
+ * per-key patch removing `meta.alt` (its one home).
75
75
  */
76
76
  async function metadataClears(adapter: PersistenceAdapter) {
77
77
  await seedHero(adapter)
78
78
  const assets = adapter.assets
79
79
  const reset = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { focalPoint: null })
80
- const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { meta: { legacyKey: 'kept' } })
80
+ const clearedAlt = await updateAssetMetadata({ assets }, PROJECT_ID, 'asset_hero', { metaPatch: { alt: null } })
81
81
  expect([reset.ok, clearedAlt.ok]).toEqual([true, true])
82
82
  const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_hero')
83
83
  return {
84
84
  focalPoint: after.focalPoint ?? null,
85
- alt: assetAlt(after),
85
+ alt: assetAlt(after, null),
86
86
  title: after.title ?? null,
87
87
  thumb: after.thumb ?? null,
88
88
  lqip: after.lqip ?? null,
@@ -131,7 +131,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
131
131
  async get() {
132
132
  // An editor saves alt while the backfill is fetching this original — the
133
133
  // backfill's write lands after it, from a listing taken before it.
134
- await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', { meta: { alt: 'Mid-run alt' } })
134
+ await updateAssetMetadata({ assets: adapter.assets }, PROJECT_ID, 'asset_big', { metaPatch: { alt: 'Mid-run alt' } })
135
135
  return new Uint8Array([1])
136
136
  },
137
137
  }
@@ -145,7 +145,7 @@ describe('EC-577 — the asset writers mean the same thing on both adapters', ()
145
145
  )
146
146
  expect(progress.generated).toBe(1)
147
147
  const after = await adapter.assets.getAsset(PROJECT_ID, 'asset_big')
148
- expect([assetAlt(after), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
148
+ expect([assetAlt(after, null), after.thumb, after.lqip]).toEqual(['Mid-run alt', THUMB, LQIP])
149
149
  }
150
150
  })
151
151
  })
@@ -70,7 +70,7 @@ export const { save, get, list, remove } = defineRecordFunctions(redirectRecordK
70
70
  /**
71
71
  * Every stored redirect of this deployment (records + the tolerant legacy read), ordered
72
72
  * by `from` — the ONE server-side reader; the delivery snapshot projects it through
73
- * `liveRedirectRecords` (EC-590 / EC-627: one enabled-redirect reader).
73
+ * `readLiveRedirects` (EC-590 / EC-627 / EC-628: the one live-redirect rule).
74
74
  */
75
75
  export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Promise<RedirectEntry[]> {
76
76
  const rows = await listRecordWire(ctx, redirectRecordKind, resolveRecordQuery(redirectRecordKind), legacyRedirects)
package/convex/schema.ts CHANGED
@@ -277,6 +277,12 @@ export default defineSchema({
277
277
  lqip: v.optional(v.string()),
278
278
  /** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. */
279
279
  meta: v.optional(v.any()),
280
+ /**
281
+ * EC-632 — the per-locale values of the asset collection's `localized: true` fields,
282
+ * `{ [locale]: { [field]: value } }` (the document mechanism beside `meta`). Optional:
283
+ * every row without one reads its shared `meta`, exactly as before.
284
+ */
285
+ localizedMeta: v.optional(v.any()),
280
286
  createdAt: v.string(),
281
287
  updatedAt: v.string(),
282
288
  }).index('by_asset', ['assetId']),
@@ -0,0 +1,139 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import { getFunctionName } from 'convex/server'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { createOperations } from '@elytracms/core/operations'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import schema from './schema'
9
+
10
+ // convex-test fires a `runAfter(0)` on a REAL timer (the publish trigger's revalidation);
11
+ // fake timers keep it queued.
12
+ beforeEach(() => {
13
+ vi.useFakeTimers()
14
+ })
15
+ afterEach(() => {
16
+ vi.useRealTimers()
17
+ })
18
+
19
+ /**
20
+ * EC-649 — Codex F08 and its reverse, on Convex: the real `cms:*` handlers, schema and
21
+ * write-chokepoint triggers under convex-test, driven by the production operations over the
22
+ * production `ConvexPersistenceAdapter`. Each race is an explicit interleaving — the client
23
+ * HOLDS one operation's store mutation after its guard, runs the other operation, then sends
24
+ * the held mutation — and the sink REFUSES it inside its transaction. convex-test serialises
25
+ * mutations, so this proves the sink's data check and its refusal, not Convex's OCC retry
26
+ * (which is what makes the same check serializable against a truly concurrent mutation).
27
+ */
28
+
29
+ const modules = {
30
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
31
+ './_generated/api.ts': async () => ({}),
32
+ }
33
+
34
+ const PROJECT_ID = 'strict-race'
35
+ const SESSION_USER_ID = 'user_admin'
36
+ const projectId = PROJECT_ID
37
+ const author = { collection: 'author', id: 'a' }
38
+ const post = { collection: 'post', id: 'p' }
39
+ const collections = () => [
40
+ { id: 'author', kind: 'document' as const, fields: [{ name: 'name', type: 'text' as const }] },
41
+ { id: 'post', kind: 'document' as const, fields: [{ name: 'author', type: 'relation' as const, target: 'author', multiple: false, strict: true }] },
42
+ ]
43
+
44
+ /** A held store mutation: `atStore` resolves once it is held; `resume` sends it. */
45
+ interface Hold {
46
+ readonly atStore: Promise<void>
47
+ readonly resume: () => void
48
+ }
49
+
50
+ async function convexWorld() {
51
+ const t = convexTest(schema, modules)
52
+ const now = new Date().toISOString()
53
+ await t.run(async (ctx) => {
54
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
55
+ })
56
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
57
+ const holds = new Map<string, { reached: () => void; proceed: Promise<void> }>()
58
+ const client = {
59
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
60
+ mutation: async (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
61
+ const key = `${getFunctionName(ref)}#${String(args?.docId)}`
62
+ const hold = holds.get(key)
63
+ if (hold) {
64
+ holds.delete(key)
65
+ hold.reached()
66
+ await hold.proceed
67
+ }
68
+ return asAdmin.mutation(ref, args ?? {})
69
+ },
70
+ } as unknown as ConvexHttpClient
71
+ const adapter = new ConvexPersistenceAdapter(client, PROJECT_ID)
72
+ const ops = createOperations({ adapter, collections, validateDocument: () => [] })
73
+ /** Hold the next `cms:<mutation>` for document `id` at the client, after every guard ran. */
74
+ const hold = (mutation: string, id: string): Hold => {
75
+ let reached!: () => void
76
+ let resume!: () => void
77
+ const atStore = new Promise<void>((resolve) => { reached = resolve })
78
+ const proceed = new Promise<void>((resolve) => { resume = resolve })
79
+ holds.set(`cms:${mutation}#${id}`, { reached, proceed })
80
+ return { atStore, resume }
81
+ }
82
+ for (const document of [
83
+ { ...author, values: { name: 'Ada' } },
84
+ { ...post, values: { author: { type: 'reference', ...author } } },
85
+ ]) expect((await ops.documents.replace({ projectId, document, precondition: { ifAbsent: true } })).ok).toBe(true)
86
+ return { adapter, ops, hold }
87
+ }
88
+
89
+ describe('EC-649 — strict-reference preconditions at the Convex sink (convex-parity)', () => {
90
+ it('F08: the author unpublished between the post’s publish guard and its mutation — `cms:publishDocument` refuses', async () => {
91
+ const { adapter, ops, hold } = await convexWorld()
92
+ expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
93
+ // Known-positive control: the sequential case is refused by the guard.
94
+ expect((await ops.documents.publish({ projectId, ref: post })).ok).toBe(true)
95
+ const blocked = await ops.documents.unpublish({ projectId, ref: author })
96
+ expect(!blocked.ok && blocked.error.code).toBe('conflict')
97
+ expect((await ops.documents.unpublish({ projectId, ref: post })).ok).toBe(true)
98
+
99
+ const held = hold('publishDocument', post.id)
100
+ const publishing = ops.documents.publish({ projectId, ref: post })
101
+ await held.atStore
102
+ expect((await ops.documents.unpublish({ projectId, ref: author })).ok).toBe(true)
103
+ held.resume()
104
+ const published = await publishing
105
+ expect(published.ok).toBe(false)
106
+ if (!published.ok) expect(published.error.messageRef?.key).toBe('operations.error.strictTargetsUnpublishedOne')
107
+ expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBeUndefined()
108
+ expect(await adapter.cms.listPublishedDocuments(projectId, 'post')).toEqual([])
109
+ })
110
+
111
+ it('the reverse: the post published between the author’s unpublish guard and its mutation — `cms:unpublishDocument` refuses', async () => {
112
+ const { adapter, ops, hold } = await convexWorld()
113
+ expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
114
+ const held = hold('unpublishDocument', author.id)
115
+ const unpublishing = ops.documents.unpublish({ projectId, ref: author })
116
+ await held.atStore // the guard found no published strict referrer
117
+ expect((await ops.documents.publish({ projectId, ref: post })).ok).toBe(true)
118
+ held.resume()
119
+ const unpublished = await unpublishing
120
+ expect(unpublished.ok).toBe(false)
121
+ if (!unpublished.ok) {
122
+ expect(unpublished.error.code).toBe('conflict')
123
+ expect(unpublished.error.messageRef?.key).toBe('operations.error.strictReferencesBlockUnpublishOne')
124
+ }
125
+ expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBe(1)
126
+ expect((await adapter.cms.getDocument(projectId, post)).publishedVersion).toBe(1)
127
+ })
128
+
129
+ it('control: the held unpublish with nothing racing it lands (the sink reads the index, finds no unjudged referrer)', async () => {
130
+ const { adapter, ops, hold } = await convexWorld()
131
+ expect((await ops.documents.publish({ projectId, ref: author })).ok).toBe(true)
132
+ const held = hold('unpublishDocument', author.id)
133
+ const unpublishing = ops.documents.unpublish({ projectId, ref: author })
134
+ await held.atStore
135
+ held.resume()
136
+ expect((await unpublishing).ok).toBe(true)
137
+ expect((await adapter.cms.getDocument(projectId, author)).publishedVersion).toBeUndefined()
138
+ })
139
+ })
package/dist/addon.d.ts CHANGED
@@ -48,7 +48,7 @@ export { assembleRailViewSource, metaBagProjection, normalizeOkResult, useRailVi
48
48
  export type { OkResult, RailAdapter } from './components/rail-view-source.js';
49
49
  export type { CollectionViewCapabilities, CollectionViewSource, FieldAccess, } from './lib/collection-view/collection-view-source.js';
50
50
  export { useOperationCache, useOperationQuery } from './lib/query/index.js';
51
- export type { OperationCache, UseOperationQueryOptions } from './lib/query/index.js';
51
+ export type { OperationCache, ReadState, UseOperationQueryOptions } from './lib/query/index.js';
52
52
  export { useStudioLocale } from './lib/locale/index.js';
53
53
  export type { StudioLocale } from './lib/locale/index.js';
54
54
  export { useSetBreadcrumb } from './components/breadcrumb-context.js';
package/dist/addon.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,0FAA0F;AAE1F,OAAO,EAAE,GAAG,EAAE,MAAM,wCAAwC,CAAA;AAkB5D,2FAA2F;AAE3F,8EAA8E;AAC9E,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,kBAAkB,EAClB,6BAA6B,EAC7B,aAAa,EACb,aAAa,GACd,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAA;AAEjH,4FAA4F;AAC5F,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EACL,sBAAsB,EACtB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AAQtC,4FAA4F;AAE5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAG/D,4FAA4F;AAE5F,4GAA4G;AAC5G,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAGpE,gGAAgG;AAEhG;;;;;GAKG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AACrE,yGAAyG;AACzG,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAChE,6FAA6F;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/studio/addon` — THE public addon API (EC-616, ruling EC-565 § 6b item 2).\n *\n * An addon (`@elytracms/forms`, `@elytracms/analytics`, or your own) is an optional\n * package whose studio half contributes navigation items to a project's\n * `defineStudio({ sidebar })` — a rail bound to a record kind, or a view with its own\n * component — and whose backend half is a record kind in the generic record store\n * (`defineRecordKind` / `adapter.records(kind)` in `@elytracms/core/persistence`, its\n * Convex functions from `defineRecordFunctions` in `@elytracms/studio/convex/\n * record-functions`). Everything an addon's studio code touches is exported HERE; the\n * studio's internals (`lib/*`, `components/*`) are not part of the published package.\n * `@elytracms/studio/ui` stays the primitive set (Button, Panel, Text, …).\n *\n * ## The chrome contract\n *\n * The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;\n * deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail with its issues\n * foot (findings contributed with {@link useNavIssues}, or always-on through a view's\n * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area\n * under it — including its own title header: the shell adds no frame, title or padding.\n * A view reaches the studio through its {@link AppViewContext}: the project, its deep\n * link and search, the operations client (every read and write goes through the one\n * authorized chokepoint), the query hooks over the one cache, the content locale, and\n * the signed-in user with their role.\n *\n * ## The two navigation items\n *\n * ```tsx\n * import { nav } from '@elytracms/studio/addon'\n * import { submissionRecordKind } from './kinds'\n *\n * // a rail bound to a record kind — the portable list/edit surfaces, no component to write\n * nav.rail(submissionRecordKind, { label: 'Submissions' })\n * // a custom component — the whole content area is yours\n * nav.view({ id: 'forms-stats', label: 'Form stats', render: (ctx) => <Stats ctx={ctx} /> })\n * ```\n */\n\n// --- Navigation items and the view contract --------------------------------------------\n\nexport { nav } from './components/app-views/default-modules'\nexport type {\n CustomViewConfig,\n RailViewConfig,\n BuiltinViewConfig,\n} from './components/app-views/default-modules'\nexport type {\n AppViewContext,\n AppViewDeepLink,\n AppViewModule,\n AppViewQueryHooks,\n NavIssuesHook,\n NavPermission,\n NavPermissionContext,\n SidebarNode,\n SidebarViewNode,\n} from './components/app-views/module'\n\n// --- Rails: a record kind, or your own source -------------------------------------------\n\n/** A record rail's pieces, for an addon composing its own view around one. */\nexport {\n RECORD_RAIL_CAPABILITIES,\n RecordRailModule,\n RecordRailProvider,\n useRecordCollectionViewSource,\n useRecordList,\n useRecordRail,\n} from './components/records/record-rail'\nexport type { RecordRailModuleProps, RecordRailPaths, RecordRailStore } from './components/records/record-rail'\nexport { lensDocumentToRecordWrite, recordLensDef, recordToLensDocument } from './components/records/record-lens'\n\n/** The portable list/edit surfaces over any source, and the scaffold that assembles one. */\nexport { CollectionViewSurface } from './components/collection-view/collection-view-surface'\nexport {\n assembleRailViewSource,\n metaBagProjection,\n normalizeOkResult,\n useRailViewSource,\n} from './components/rail-view-source'\nexport type { OkResult, RailAdapter } from './components/rail-view-source'\nexport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from './lib/collection-view/collection-view-source'\n\n// --- Hooks: the cache, the locale, the breadcrumb, the rail foot -------------------------\n\nexport { useOperationCache, useOperationQuery } from './lib/query'\nexport type { OperationCache, UseOperationQueryOptions } from './lib/query'\nexport { useStudioLocale } from './lib/locale'\nexport type { StudioLocale } from './lib/locale'\nexport { useSetBreadcrumb } from './components/breadcrumb-context'\nexport type { BreadcrumbItem } from './components/breadcrumb-context'\nexport { useNavIssues } from './components/nav-issues-registry'\nexport type { NavIssue } from './components/nav-issues-registry'\n\n// --- The document editor: contributed inspector panes (EC-618) ---------------------------\n\n/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */\nexport { documentPane } from './components/inspector/document-panes'\nexport type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes'\n\n// --- EC-613 (the forms addon): an own source's edit shell, its list key, the UI language -----\n\n/**\n * The portable edit shell alone (AD-19), with its form-column appendix seam (EC-396): an addon\n * that assembles its own source (`useRailViewSource`) renders one record's detail with\n * read-only sections below the editable fields — the forms inbox shows a submission's answers,\n * files and delivery log there.\n */\nexport { PortableDetailEditor } from './components/cms/detail-editor'\n/** The one query key of a kind's records in a project — patch it after a write (`useOperationCache`). */\nexport { recordListKey } from './components/records/record-rail'\n/** Resolve a `LocalizedString` (`'Text'` or `{ en, de, … }`) in the studio's UI language. */\nexport { useLabel } from './lib/i18n'\n"]}
1
+ {"version":3,"file":"addon.js","sourceRoot":"","sources":["../src/addon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,0FAA0F;AAE1F,OAAO,EAAE,GAAG,EAAE,MAAM,wCAAwC,CAAA;AAkB5D,2FAA2F;AAE3F,8EAA8E;AAC9E,OAAO,EACL,wBAAwB,EACxB,gBAAgB,EAChB,kBAAkB,EAClB,6BAA6B,EAC7B,aAAa,EACb,aAAa,GACd,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAA;AAEjH,4FAA4F;AAC5F,OAAO,EAAE,qBAAqB,EAAE,MAAM,sDAAsD,CAAA;AAC5F,OAAO,EACL,sBAAsB,EACtB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,+BAA+B,CAAA;AAQtC,4FAA4F;AAE5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAElE,OAAO,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAG/D,4FAA4F;AAE5F,4GAA4G;AAC5G,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AAGpE,gGAAgG;AAEhG;;;;;GAKG;AACH,OAAO,EAAE,oBAAoB,EAAE,MAAM,gCAAgC,CAAA;AACrE,yGAAyG;AACzG,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAA;AAChE,6FAA6F;AAC7F,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/studio/addon` — THE public addon API (EC-616, ruling EC-565 § 6b item 2).\n *\n * An addon (`@elytracms/forms`, `@elytracms/analytics`, or your own) is an optional\n * package whose studio half contributes navigation items to a project's\n * `defineStudio({ sidebar })` — a rail bound to a record kind, or a view with its own\n * component — and whose backend half is a record kind in the generic record store\n * (`defineRecordKind` / `adapter.records(kind)` in `@elytracms/core/persistence`, its\n * Convex functions from `defineRecordFunctions` in `@elytracms/studio/convex/\n * record-functions`). Everything an addon's studio code touches is exported HERE; the\n * studio's internals (`lib/*`, `components/*`) are not part of the published package.\n * `@elytracms/studio/ui` stays the primitive set (Button, Panel, Text, …).\n *\n * ## The chrome contract\n *\n * The SHELL draws the top bar, the breadcrumb (its section crumb is the view's `label`;\n * deeper crumbs are contributed with {@link useSetBreadcrumb}), the rail with its issues\n * foot (findings contributed with {@link useNavIssues}, or always-on through a view's\n * `useIssues`), the banners and the theme. A VIEW owns the whole scrollable content area\n * under it — including its own title header: the shell adds no frame, title or padding.\n * A view reaches the studio through its {@link AppViewContext}: the project, its deep\n * link and search, the operations client (every read and write goes through the one\n * authorized chokepoint), the query hooks over the one cache, the content locale, and\n * the signed-in user with their role.\n *\n * ## The two navigation items\n *\n * ```tsx\n * import { nav } from '@elytracms/studio/addon'\n * import { submissionRecordKind } from './kinds'\n *\n * // a rail bound to a record kind — the portable list/edit surfaces, no component to write\n * nav.rail(submissionRecordKind, { label: 'Submissions' })\n * // a custom component — the whole content area is yours\n * nav.view({ id: 'forms-stats', label: 'Form stats', render: (ctx) => <Stats ctx={ctx} /> })\n * ```\n */\n\n// --- Navigation items and the view contract --------------------------------------------\n\nexport { nav } from './components/app-views/default-modules'\nexport type {\n CustomViewConfig,\n RailViewConfig,\n BuiltinViewConfig,\n} from './components/app-views/default-modules'\nexport type {\n AppViewContext,\n AppViewDeepLink,\n AppViewModule,\n AppViewQueryHooks,\n NavIssuesHook,\n NavPermission,\n NavPermissionContext,\n SidebarNode,\n SidebarViewNode,\n} from './components/app-views/module'\n\n// --- Rails: a record kind, or your own source -------------------------------------------\n\n/** A record rail's pieces, for an addon composing its own view around one. */\nexport {\n RECORD_RAIL_CAPABILITIES,\n RecordRailModule,\n RecordRailProvider,\n useRecordCollectionViewSource,\n useRecordList,\n useRecordRail,\n} from './components/records/record-rail'\nexport type { RecordRailModuleProps, RecordRailPaths, RecordRailStore } from './components/records/record-rail'\nexport { lensDocumentToRecordWrite, recordLensDef, recordToLensDocument } from './components/records/record-lens'\n\n/** The portable list/edit surfaces over any source, and the scaffold that assembles one. */\nexport { CollectionViewSurface } from './components/collection-view/collection-view-surface'\nexport {\n assembleRailViewSource,\n metaBagProjection,\n normalizeOkResult,\n useRailViewSource,\n} from './components/rail-view-source'\nexport type { OkResult, RailAdapter } from './components/rail-view-source'\nexport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from './lib/collection-view/collection-view-source'\n\n// --- Hooks: the cache, the locale, the breadcrumb, the rail foot -------------------------\n\nexport { useOperationCache, useOperationQuery } from './lib/query'\nexport type { OperationCache, ReadState, UseOperationQueryOptions } from './lib/query'\nexport { useStudioLocale } from './lib/locale'\nexport type { StudioLocale } from './lib/locale'\nexport { useSetBreadcrumb } from './components/breadcrumb-context'\nexport type { BreadcrumbItem } from './components/breadcrumb-context'\nexport { useNavIssues } from './components/nav-issues-registry'\nexport type { NavIssue } from './components/nav-issues-registry'\n\n// --- The document editor: contributed inspector panes (EC-618) ---------------------------\n\n/** A pane in the document editor's inspector — `defineStudio({ documentPanes: [documentPane({ … })] })`. */\nexport { documentPane } from './components/inspector/document-panes'\nexport type { DocumentPane, DocumentPaneContext } from './components/inspector/document-panes'\n\n// --- EC-613 (the forms addon): an own source's edit shell, its list key, the UI language -----\n\n/**\n * The portable edit shell alone (AD-19), with its form-column appendix seam (EC-396): an addon\n * that assembles its own source (`useRailViewSource`) renders one record's detail with\n * read-only sections below the editable fields — the forms inbox shows a submission's answers,\n * files and delivery log there.\n */\nexport { PortableDetailEditor } from './components/cms/detail-editor'\n/** The one query key of a kind's records in a project — patch it after a write (`useOperationCache`). */\nexport { recordListKey } from './components/records/record-rail'\n/** Resolve a `LocalizedString` (`'Text'` or `{ en, de, … }`) in the studio's UI language. */\nexport { useLabel } from './lib/i18n'\n"]}
@@ -66,6 +66,12 @@ export function AiChatPanel() {
66
66
  ...doc.values,
67
67
  ...(doc.localized?.[activeLocale] ?? {}),
68
68
  }), [activeLocale]);
69
+ /**
70
+ * EC-647 — the revision each document the assistant READ was at, keyed by the value the
71
+ * reader returned (the token the proposal is built from): an update / translation
72
+ * proposal records it, and its apply writes at it.
73
+ */
74
+ const readRevisions = useRef(new WeakMap());
69
75
  /** Operations-backed tool readers — client-side, with the user's own session. */
70
76
  const readers = useMemo(() => {
71
77
  const matches = (query, ...fields) => !query || fields.some((f) => f?.toLowerCase().includes(query.toLowerCase()));
@@ -118,8 +124,12 @@ export function AiChatPanel() {
118
124
  await cms.flushPersistence({ collection, id });
119
125
  const { operations } = await getStudioOperations();
120
126
  const result = await operations.documents.get({ projectId, ref: { collection, id } });
121
- return result.ok ? result.value?.document ?? null : null;
127
+ if (!result.ok || !result.value)
128
+ return null;
129
+ readRevisions.current.set(result.value.document, result.value.revision);
130
+ return result.value.document;
122
131
  },
132
+ revisionOf: (document) => readRevisions.current.get(document),
123
133
  };
124
134
  }, [projectId, cms, localeValues]);
125
135
  /** EC-578: a fresh document id from the session's injected id factory (UUIDs). */
@@ -338,7 +348,10 @@ export function AiChatPanel() {
338
348
  if (current && current.collection === ref.collection && current.id === ref.id)
339
349
  await current.flush();
340
350
  if (existing && proposal.patches) {
341
- const outcome = await cms.commitPatches(ref, proposal.patches);
351
+ // EC-647: AT the revision of the read the proposal was built from — a position
352
+ // changed since then by anyone else is refused (the apply fails visibly), never
353
+ // overwritten by content the assistant never saw.
354
+ const outcome = await cms.commitPatches(ref, proposal.patches, proposal.patches.map(() => proposal.revision));
342
355
  if (outcome?.status === 'failed' || outcome?.status === 'refused') {
343
356
  dispatch({ type: 'fail', message: translate('ai.error.applyFailed') });
344
357
  return;