@elytracms/studio 0.0.100 → 0.0.102

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 (170) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/asset-meta-patch.test.ts +7 -27
  3. package/convex/assets.ts +6 -41
  4. package/convex/cms.ts +2 -29
  5. package/convex/delivery.ts +2 -2
  6. package/convex/record-functions.ts +15 -82
  7. package/convex/redirects.ts +11 -116
  8. package/convex/schema.ts +4 -23
  9. package/convex/shared-capture.convex.test.ts +165 -0
  10. package/convex/triggers.ts +7 -14
  11. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  12. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  13. package/dist/components/ai/ai-chat-panel.js +8 -5
  14. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  15. package/dist/components/ai/proposal-card.js +2 -1
  16. package/dist/components/ai/proposal-card.js.map +1 -1
  17. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  18. package/dist/components/ai/proposal-unfilled.js +33 -0
  19. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  20. package/dist/components/ai/translate-document-action.js +8 -6
  21. package/dist/components/ai/translate-document-action.js.map +1 -1
  22. package/dist/components/ai/translate-skipped.js +1 -0
  23. package/dist/components/ai/translate-skipped.js.map +1 -1
  24. package/dist/components/app-views/studio-config.d.ts +12 -12
  25. package/dist/components/app-views/studio-config.js.map +1 -1
  26. package/dist/components/assets/asset-detail.js +1 -2
  27. package/dist/components/assets/asset-detail.js.map +1 -1
  28. package/dist/components/assets/asset-metadata.d.ts +2 -9
  29. package/dist/components/assets/asset-metadata.js +3 -10
  30. package/dist/components/assets/asset-metadata.js.map +1 -1
  31. package/dist/components/assets/asset-search.js +1 -1
  32. package/dist/components/assets/asset-search.js.map +1 -1
  33. package/dist/components/assets/asset-selection.js +2 -2
  34. package/dist/components/assets/asset-selection.js.map +1 -1
  35. package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
  36. package/dist/components/assets/assets-collection-view-source.js +2 -3
  37. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  38. package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
  39. package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
  40. package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
  41. package/dist/components/builder/builder-store.d.ts +37 -1
  42. package/dist/components/builder/builder-store.js +21 -6
  43. package/dist/components/builder/builder-store.js.map +1 -1
  44. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  45. package/dist/components/builder/canvas-chrome-css.js +1 -1
  46. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  47. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  48. package/dist/components/builder/canvas-chrome.js +1 -1
  49. package/dist/components/builder/canvas-chrome.js.map +1 -1
  50. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  51. package/dist/components/builder/canvas-rebase.js +3 -2
  52. package/dist/components/builder/canvas-rebase.js.map +1 -1
  53. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  54. package/dist/components/builder/canvas-render-context.js +1 -1
  55. package/dist/components/builder/canvas-render-context.js.map +1 -1
  56. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  57. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  58. package/dist/components/builder/composition-field-editor.js +3 -3
  59. package/dist/components/builder/composition-field-editor.js.map +1 -1
  60. package/dist/components/builder/reuse-context.d.ts +9 -2
  61. package/dist/components/builder/reuse-context.js +46 -6
  62. package/dist/components/builder/reuse-context.js.map +1 -1
  63. package/dist/components/builder/reuse.d.ts +113 -70
  64. package/dist/components/builder/reuse.js +123 -108
  65. package/dist/components/builder/reuse.js.map +1 -1
  66. package/dist/components/cms/asset-field.js +1 -3
  67. package/dist/components/cms/asset-field.js.map +1 -1
  68. package/dist/components/cms/cell.d.ts +2 -2
  69. package/dist/components/cms/cell.js +8 -18
  70. package/dist/components/cms/cell.js.map +1 -1
  71. package/dist/components/cms/cms-store.d.ts +9 -1
  72. package/dist/components/cms/cms-store.js +1 -0
  73. package/dist/components/cms/cms-store.js.map +1 -1
  74. package/dist/components/cms/detail-editor.js +15 -14
  75. package/dist/components/cms/detail-editor.js.map +1 -1
  76. package/dist/components/cms/field-control.js +13 -1
  77. package/dist/components/cms/field-control.js.map +1 -1
  78. package/dist/components/cms/prop-fields.js +1 -1
  79. package/dist/components/cms/prop-fields.js.map +1 -1
  80. package/dist/components/cms/rich-text/lazy.js +2 -1
  81. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  82. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  83. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  84. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  85. package/dist/components/cms/use-field-focus.js +3 -3
  86. package/dist/components/cms/use-field-focus.js.map +1 -1
  87. package/dist/components/cms/version-compare-lazy.js +2 -1
  88. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  89. package/dist/components/components/components-index.js +1 -1
  90. package/dist/components/components/components-index.js.map +1 -1
  91. package/dist/components/deployment-setup-gate.d.ts +1 -1
  92. package/dist/components/deployment-setup-gate.js +1 -1
  93. package/dist/components/deployment-setup-gate.js.map +1 -1
  94. package/dist/components/load-failure.d.ts +62 -0
  95. package/dist/components/load-failure.js +110 -0
  96. package/dist/components/load-failure.js.map +1 -0
  97. package/dist/components/notifications/shell-findings.js +3 -3
  98. package/dist/components/notifications/shell-findings.js.map +1 -1
  99. package/dist/customization.d.ts +3 -3
  100. package/dist/customization.js.map +1 -1
  101. package/dist/lib/ai/apply-content.js +99 -28
  102. package/dist/lib/ai/apply-content.js.map +1 -1
  103. package/dist/lib/ai/chat-context.d.ts +77 -7
  104. package/dist/lib/ai/chat-context.js +139 -14
  105. package/dist/lib/ai/chat-context.js.map +1 -1
  106. package/dist/lib/ai/chat-state.d.ts +7 -1
  107. package/dist/lib/ai/chat-state.js.map +1 -1
  108. package/dist/lib/ai/constraints.d.ts +5 -0
  109. package/dist/lib/ai/constraints.js +21 -6
  110. package/dist/lib/ai/constraints.js.map +1 -1
  111. package/dist/lib/ai/fake-provider.d.ts +76 -26
  112. package/dist/lib/ai/fake-provider.js +210 -66
  113. package/dist/lib/ai/fake-provider.js.map +1 -1
  114. package/dist/lib/ai/index.d.ts +1 -0
  115. package/dist/lib/ai/index.js +1 -0
  116. package/dist/lib/ai/index.js.map +1 -1
  117. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  118. package/dist/lib/ai/keyless-assistant.js +193 -0
  119. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  120. package/dist/lib/ai/knowledge.d.ts +9 -9
  121. package/dist/lib/ai/knowledge.js +22 -0
  122. package/dist/lib/ai/knowledge.js.map +1 -1
  123. package/dist/lib/ai/shape.d.ts +25 -2
  124. package/dist/lib/ai/shape.js +58 -7
  125. package/dist/lib/ai/shape.js.map +1 -1
  126. package/dist/lib/ai/tools.d.ts +18 -1
  127. package/dist/lib/ai/tools.js +74 -11
  128. package/dist/lib/ai/tools.js.map +1 -1
  129. package/dist/lib/ai/translate-document.d.ts +8 -1
  130. package/dist/lib/ai/translate-document.js +25 -9
  131. package/dist/lib/ai/translate-document.js.map +1 -1
  132. package/dist/lib/ai/types.d.ts +22 -7
  133. package/dist/lib/ai/types.js.map +1 -1
  134. package/dist/lib/ai/update-fields.js +13 -17
  135. package/dist/lib/ai/update-fields.js.map +1 -1
  136. package/dist/lib/ai/writer-form.d.ts +72 -3
  137. package/dist/lib/ai/writer-form.js +320 -42
  138. package/dist/lib/ai/writer-form.js.map +1 -1
  139. package/dist/lib/assets/upload.d.ts +1 -2
  140. package/dist/lib/assets/upload.js +1 -2
  141. package/dist/lib/assets/upload.js.map +1 -1
  142. package/dist/lib/auth-errors.d.ts +1 -1
  143. package/dist/lib/auth-errors.js +1 -1
  144. package/dist/lib/auth-errors.js.map +1 -1
  145. package/dist/lib/engine-version.d.ts +1 -1
  146. package/dist/lib/engine-version.js +1 -1
  147. package/dist/lib/engine-version.js.map +1 -1
  148. package/dist/lib/i18n/messages.de.d.ts +1 -1
  149. package/dist/lib/i18n/messages.de.js +32 -8
  150. package/dist/lib/i18n/messages.de.js.map +1 -1
  151. package/dist/lib/i18n/messages.en.d.ts +25 -3
  152. package/dist/lib/i18n/messages.en.js +40 -10
  153. package/dist/lib/i18n/messages.en.js.map +1 -1
  154. package/dist/lib/operations/cms-persistence.d.ts +28 -0
  155. package/dist/lib/operations/cms-persistence.js +55 -0
  156. package/dist/lib/operations/cms-persistence.js.map +1 -1
  157. package/dist/lib/persistence/convex-adapter.js +7 -2
  158. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  159. package/dist/lib/workspace/index.d.ts +3 -3
  160. package/dist/lib/workspace/index.js +3 -3
  161. package/dist/lib/workspace/index.js.map +1 -1
  162. package/dist/router/navigate-once.d.ts +1 -1
  163. package/dist/router/navigate-once.js.map +1 -1
  164. package/dist/router/root.d.ts +3 -2
  165. package/dist/router/root.js +25 -4
  166. package/dist/router/root.js.map +1 -1
  167. package/package.json +3 -3
  168. package/convex/asset-alt-migration.test.ts +0 -127
  169. package/convex/redirect-migration.test.ts +0 -205
  170. package/convex/version-kind-stamping.test.ts +0 -118
@@ -485,7 +485,7 @@ function pick<T>(random: () => number, weighted: readonly (readonly [T, number])
485
485
  }
486
486
 
487
487
  const SLUGS = [
488
- 'sommerfest-im-garten-der-konditorei',
488
+ 'sommerfest-im-garten-der-stadthalle',
489
489
  'unsere-torten-fuer-hochzeiten',
490
490
  'ueber-uns',
491
491
  'kontakt-und-anfahrt',
@@ -4,7 +4,7 @@ import { convexTest } from 'convex-test'
4
4
  import type { ConvexHttpClient } from 'convex/browser'
5
5
  import type { FunctionReference } from 'convex/server'
6
6
  import { defaultAssetCollection, measureMaxDepth } from '@elytracms/core/cms-core'
7
- import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError, assetAlt } from '@elytracms/core/persistence'
7
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError } from '@elytracms/core/persistence'
8
8
  import type { AssetRepository } from '@elytracms/core/persistence'
9
9
  import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
10
  import { runUpload } from '../src/lib/assets/upload'
@@ -29,8 +29,7 @@ afterEach(() => {
29
29
  * EC-634 — an asset's `meta` is written per key, inside the mutation, on BOTH adapters:
30
30
  * two sessions (two adapters over ONE backend — two studio tabs on one deployment) whose
31
31
  * dialog saves interleave keep both keys; a dialog save landing while a file replace is in
32
- * flight survives the replace; a not-yet-migrated legacy `alt` column is lifted into the
33
- * stored bag by the same transaction. convex-test serialises mutations, so this proves the
32
+ * flight survives the replace. convex-test serialises mutations, so this proves the
34
33
  * per-key MERGE in `assets:upsert`, not Convex's OCC retry (which re-runs the same handler
35
34
  * against the newer row — the merge is what makes that retry correct).
36
35
  */
@@ -42,7 +41,6 @@ const modules = {
42
41
 
43
42
  const PROJECT_ID = 'asset-meta-patch'
44
43
  const SESSION_USER_ID = 'user_admin'
45
- const STAMP = '2026-10-27T00:00:00.000Z'
46
44
 
47
45
  async function convexBackend() {
48
46
  const t = convexTest(schema, modules)
@@ -161,31 +159,13 @@ describe('EC-634 — asset meta is patched per key in the write, on both adapter
161
159
  expect(convex).toMatchObject({ alt: 'A harbour at dusk & boats', note: 'B note' })
162
160
  })
163
161
 
164
- it('a patch lifts a not-yet-migrated legacy alt column into the stored bag, in the same mutation', async () => {
162
+ it('EC-708: a write naming alt is refused before the wire — the mutation declares no alt argument', async () => {
165
163
  const { t, a } = await convexBackend()
166
- await t.run(async (ctx) => {
167
- await ctx.db.insert('assets', {
168
- assetId: 'legacy',
169
- filename: 'l.jpg',
170
- contentType: 'image/jpeg',
171
- byteSize: 1,
172
- storageKey: 'k_legacy',
173
- alt: 'Column alt',
174
- createdAt: STAMP,
175
- updatedAt: STAMP,
176
- })
177
- })
178
- // An edit of ANOTHER key: the alt it did not name moves home and is kept.
179
- const kept = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { credit: 'Ada' } })
180
- expect(kept.meta).toEqual({ alt: 'Column alt', credit: 'Ada' })
181
- expect(kept.alt).toBeUndefined()
182
- expect(assetAlt(kept, null)).toBe('Column alt')
183
- // A clear of the alt stays cleared — no column left to re-surface it (ASSETS D21).
184
- const cleared = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { alt: null } })
185
- expect(assetAlt(cleared, null)).toBeNull()
186
- const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'legacy')).first())
187
- expect(row?.alt).toBeUndefined()
164
+ await seed(a, { credit: 'Ada' })
165
+ await expect(a.upsertAsset(PROJECT_ID, { id: 'hero', alt: 'Column alt' } as never)).rejects.toThrow(/meta\.alt/)
166
+ const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'hero')).first())
188
167
  expect(row?.meta).toEqual({ credit: 'Ada' })
168
+ expect(row && 'alt' in row).toBe(false)
189
169
  })
190
170
 
191
171
  it('EC-632 — per-locale buckets are stored by the real mutation, and the stored row measures its depth', async () => {
package/convex/assets.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { ConvexError, v } from 'convex/values'
2
2
  import type { GenericId, Validator } from 'convex/values'
3
3
  import type { DocumentByName, WithoutSystemFields } from 'convex/server'
4
- import { liftLegacyAlt as legacyAltLift, rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
4
+ import { rejectedWriteMessage, resolveAssetWrite } from '@elytracms/core/persistence'
5
5
  import type { DataModel } from './functions'
6
- import { internalMutation, mutation, query } from './functions'
6
+ import { mutation, query } from './functions'
7
7
  import { requireCapability } from './guard'
8
8
  import { ingestBindingHolds } from './ingest'
9
9
 
@@ -26,9 +26,10 @@ const clearable = <T, F extends string>(validator: Validator<T, 'required', F>)
26
26
  /**
27
27
  * Write one asset (EC-577). The ONE write rule (`resolveAssetWrite`, shared with
28
28
  * the in-memory adapter) runs here, inside the transaction: a column absent from
29
- * the args keeps its stored value, `null` clears it, a value sets it — the legacy
30
- * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`), and a
31
- * `metaPatch` changes `meta` per key against the row read here (EC-634). An edit is
29
+ * the args keeps its stored value, `null` clears it, a value sets it — there is no `alt`
30
+ * argument (EC-708: an asset's alt is `meta.alt`; the adapter refuses an `alt` write
31
+ * before the wire), and a `metaPatch` changes `meta` per key against the row read here
32
+ * (EC-634). An edit is
32
33
  * ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
33
34
  * which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
34
35
  * asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
@@ -44,13 +45,6 @@ export const upsert = mutation({
44
45
  storageKey: v.optional(v.string()),
45
46
  width: clearable(v.number()),
46
47
  height: clearable(v.number()),
47
- /**
48
- * LEGACY column (EC-587): accepted on the wire only so a write that still sets it
49
- * is refused BY THE RULE with a message naming `meta.alt` (not a bare validator
50
- * error); `null` clears it. Dropped with the column once `liftLegacyAlt` ran on
51
- * every deployment.
52
- */
53
- alt: clearable(v.string()),
54
48
  title: clearable(v.string()),
55
49
  url: clearable(v.string()),
56
50
  focalPoint: clearable(v.object({ x: v.number(), y: v.number() })),
@@ -124,35 +118,6 @@ export const upsert = mutation({
124
118
  },
125
119
  })
126
120
 
127
- /**
128
- * EC-587 — the one-shot migration of the legacy `alt` column: every asset row holding
129
- * it gets the value lifted into `meta.alt` (unless `meta` already carries an `alt`
130
- * key, which wins — it is what every reader already reads) and the column unset, by
131
- * the ONE pure rule (core `liftLegacyAlt`, shared with the in-memory runner
132
- * `liftLegacyAssetAlts`). Idempotent: a second run lifts nothing.
133
- *
134
- * Internal: run by an admin from the CLI, never by a client —
135
- * `npx convex run assets:liftLegacyAlt` (no args). Each lifted row is one `db.patch`
136
- * through the write-chokepoint triggers, so it schedules the asset's revalidation like
137
- * any asset edit (the delivered alt is unchanged by the lift; the sweep is harmless).
138
- * One transaction: Convex bounds it at 8192 written rows, far above any v1 library —
139
- * documented, not paginated (as `cms:clearDocuments`).
140
- */
141
- export const liftLegacyAlt = internalMutation({
142
- args: {},
143
- handler: async (ctx) => {
144
- const rows = await ctx.db.query('assets').collect()
145
- const lifted: string[] = []
146
- for (const row of rows) {
147
- const patch = legacyAltLift(row)
148
- if (!patch) continue
149
- await ctx.db.patch(row._id, patch as Partial<AssetDoc>)
150
- lifted.push(row.assetId)
151
- }
152
- return { scanned: rows.length, lifted }
153
- },
154
- })
155
-
156
121
  export const get = query({
157
122
  args: { assetId: v.string() },
158
123
  handler: async (ctx, args) => {
package/convex/cms.ts CHANGED
@@ -12,9 +12,9 @@ import {
12
12
  unfoldValue,
13
13
  } from '@elytracms/core/cms-core'
14
14
  import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
15
- import { isPinnableVersion, planVersionKindStamps } from '@elytracms/core/persistence'
15
+ import { isPinnableVersion } from '@elytracms/core/persistence'
16
16
  import type { DocumentVersionKind } from '@elytracms/core/persistence'
17
- import { internalMutation, mutation, query } from './functions'
17
+ import { mutation, query } from './functions'
18
18
  import type { MutationCtx, QueryCtx } from './functions'
19
19
  import { requireCapability } from './guard'
20
20
  import { assertWithinWriteLimits } from './write-limits'
@@ -866,33 +866,6 @@ export const listAllDocumentVersions = query({
866
866
  },
867
867
  })
868
868
 
869
- /**
870
- * EC-707 — the EC-585 kind stamping: every version row recorded before EC-585 (no `kind`)
871
- * gets the kind `isPinnableVersion` already reads it as — `label: 'published'` → `'publish'`,
872
- * anything else → `'snapshot'` (core `planVersionKindStamps`, the same inference) — so
873
- * pinnability is unchanged for every row, and once every deployment reads 0 kind-less rows
874
- * the tolerant branch and the optional `kind` can go (EC-707's deletions). Idempotent: a
875
- * second run stamps 0; a row that names a kind is never touched; nothing but `kind` is written.
876
- *
877
- * Internal: an admin runs it from the CLI, once per deployment — `npx convex run
878
- * cms:stampVersionKinds` (no args) → `{ scanned, stamped }`; `pnpm demo:reseed` runs it twice.
879
- * ONE transaction at a documented bound (`VERSION_KIND_STAMP_BOUND`: 4096 rows / 4 MiB of
880
- * rewritten rows, half of Convex's per-transaction write limits): it reads every version row,
881
- * as `listAllDocumentVersions` already does for delivery, and above the bound it throws
882
- * before writing anything — a refusal is whole, never a partial stamping. `documentVersions`
883
- * has no write-chokepoint trigger, so the stamp schedules no revalidation (delivery reads the
884
- * same pins before and after).
885
- */
886
- export const stampVersionKinds = internalMutation({
887
- args: {},
888
- handler: async (ctx) => {
889
- const rows = await ctx.db.query('documentVersions').collect()
890
- const stamps = planVersionKindStamps(rows)
891
- for (const { row, kind } of stamps) await ctx.db.patch(row._id, { kind })
892
- return { scanned: rows.length, stamped: stamps.length }
893
- },
894
- })
895
-
896
869
  /**
897
870
  * Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
898
871
  * enforced here, in the transaction: the version must exist for THIS document
@@ -181,8 +181,8 @@ export async function loadSnapshot(
181
181
 
182
182
  // Editorial redirects (EC-303): only ENABLED entries serve — a disabled redirect is
183
183
  // stored but never live, in either perspective. EC-616: read through the redirect
184
- // kind's one server-side reader (records + the marked legacy tolerant read) and
185
- // decided by the ONE live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
184
+ // kind's one server-side reader (the `records` store) and decided by the ONE
185
+ // live-redirect rule, `readLiveRedirects` (EC-590 / EC-627 — this
186
186
  // was the last hand-built copy on Convex). EC-628: a stored redirect the rule cannot
187
187
  // read (a non-boolean switch, a non-string `to`) is withheld AND reported — the host
188
188
  // carries `redirectIssues` into its source issues — instead of being served or dropped
@@ -3,7 +3,6 @@ import type { GenericId } from 'convex/values'
3
3
  import type { RegisteredMutation, RegisteredQuery } from 'convex/server'
4
4
  import {
5
5
  PersistenceValidationError,
6
- matchesRecordQuery,
7
6
  orderRecords,
8
7
  recordSlots,
9
8
  recordWireRow,
@@ -65,33 +64,6 @@ type RecordDoc = {
65
64
  updatedAt: string
66
65
  }
67
66
 
68
- /**
69
- * TOLERANT READ (marked; EC-616's redirect move, the EC-587 precedent): a kind whose rows
70
- * used to live in a table of their own declares where, so a deployment whose functions
71
- * moved but whose migration has not run yet serves exactly what it served before — no
72
- * gap. Reads union the legacy rows a record does not shadow; a save of a legacy-only id
73
- * lifts it (the record is created from the legacy columns, the legacy row deleted, one
74
- * transaction); a remove deletes both. Writes NEVER go to the legacy table. Removed with
75
- * the legacy table once its migration is reported run on every deployment (a gated item).
76
- */
77
- export interface LegacyRecordSource {
78
- /** The legacy row for one id: its columns and stamps, and a handle to delete it. */
79
- find(ctx: QueryCtx | MutationCtx, recordId: string): Promise<LegacyRecord | null>
80
- /** Every legacy row (the tables are small editorial sets: read whole). */
81
- list(ctx: QueryCtx | MutationCtx): Promise<LegacyRecord[]>
82
- /** Delete one legacy row (after its record exists). */
83
- remove(ctx: MutationCtx, record: LegacyRecord): Promise<void>
84
- }
85
-
86
- export interface LegacyRecord {
87
- recordId: string
88
- columns: RecordColumns
89
- createdAt: string
90
- updatedAt: string
91
- /** The legacy row's own id, opaque to the factory. */
92
- handle: unknown
93
- }
94
-
95
67
  /** A `PersistenceValidationError` from the shared rule, as the `ConvexError` the client maps back. */
96
68
  function asConvexValidation(error: unknown): unknown {
97
69
  if (!(error instanceof PersistenceValidationError)) return error
@@ -161,47 +133,26 @@ function indexedDoc(doc: RecordDoc): IndexedRecord & { doc: RecordDoc } {
161
133
  }
162
134
 
163
135
  /**
164
- * The wire rows of one kind for a query — the records, plus (while a legacy source is
165
- * declared) the legacy rows no record shadows, matched and ordered by the SAME pure
166
- * functions the in-memory store uses. The one list reader: the kind's `list` and any
167
- * server-side reader of the kind (the delivery snapshot) go through it.
136
+ * The wire rows of one kind for a query, matched and ordered by the store's query. The one
137
+ * list reader: the kind's `list` and any server-side reader of the kind (the delivery
138
+ * snapshot) go through it.
168
139
  */
169
140
  export async function listRecordWire(
170
141
  ctx: QueryCtx | MutationCtx,
171
142
  kind: AnyRecordKind,
172
143
  resolved: ResolvedRecordQuery,
173
- legacy?: LegacyRecordSource,
174
144
  ): Promise<RecordWireRow[]> {
175
- if (!legacy) return (await queryRecordDocs(ctx, kind, resolved)).map((doc) => recordDocWire(kind, doc))
176
- // TOLERANT READ (marked): the union is read unbounded and ordered once, here.
177
- const { limit: _limit, ...unbounded } = resolved
178
- const docs = await queryRecordDocs(ctx, kind, { ...unbounded, order: 'asc' })
179
- const shadowed = new Set(docs.map((doc) => doc.recordId))
180
- const legacyRows = (await legacy.list(ctx))
181
- .filter((row) => !shadowed.has(row.recordId))
182
- .map((row) => ({
183
- id: row.recordId,
184
- slots: recordSlots(kind, row.columns, row.createdAt),
185
- columns: row.columns,
186
- wire: recordWireRow(kind, row.recordId, row.columns, row.createdAt, row.updatedAt),
187
- }))
188
- .filter((row) => matchesRecordQuery(row, resolved))
189
- // Legacy rows predate every record, so they lead in insertion order.
190
- const union = [...legacyRows, ...docs.map((doc) => ({ ...indexedDoc(doc), wire: recordDocWire(kind, doc) }))]
191
- return orderRecords(kind, union, resolved).map((row) => row.wire)
145
+ return (await queryRecordDocs(ctx, kind, resolved)).map((doc) => recordDocWire(kind, doc))
192
146
  }
193
147
 
194
- /** One record's wire row by id — the record, else (while declared) its legacy row. */
148
+ /** One record's wire row by id. */
195
149
  export async function getRecordWire(
196
150
  ctx: QueryCtx | MutationCtx,
197
151
  kind: AnyRecordKind,
198
152
  recordId: string,
199
- legacy?: LegacyRecordSource,
200
153
  ): Promise<RecordWireRow | null> {
201
154
  const doc = await findRecordDoc(ctx, kind, recordId)
202
- if (doc) return recordDocWire(kind, doc)
203
- const old = legacy ? await legacy.find(ctx, recordId) : null // TOLERANT READ (marked)
204
- return old ? recordWireRow(kind, old.recordId, old.columns, old.createdAt, old.updatedAt) : null
155
+ return doc ? recordDocWire(kind, doc) : null
205
156
  }
206
157
 
207
158
  /**
@@ -214,14 +165,10 @@ export async function saveRecord(
214
165
  recordId: string,
215
166
  write: Readonly<Record<string, unknown>>,
216
167
  stamps: { createdAt: string; updatedAt: string },
217
- legacy?: LegacyRecordSource,
218
168
  ): Promise<RecordWireRow> {
219
169
  const existing = await findRecordDoc(ctx, kind, recordId)
220
- // TOLERANT READ (marked): a legacy-only record is edited from its legacy columns.
221
- const lifted = !existing && legacy ? await legacy.find(ctx, recordId) : null
222
- const stored = existing?.data ?? lifted?.columns
223
- const columns = rethrowAsConvex(() => resolveRecordSave(kind, recordId, stored, write))
224
- const createdAt = existing?.createdAt ?? lifted?.createdAt ?? stamps.createdAt
170
+ const columns = rethrowAsConvex(() => resolveRecordSave(kind, recordId, existing?.data, write))
171
+ const createdAt = existing?.createdAt ?? stamps.createdAt
225
172
  const slots = recordSlots(kind, columns, createdAt)
226
173
  if (existing) {
227
174
  // `key1`/`key2` are always written: an edit that empties a key unsets its slot.
@@ -243,7 +190,6 @@ export async function saveRecord(
243
190
  createdAt,
244
191
  updatedAt: stamps.updatedAt,
245
192
  })
246
- if (lifted && legacy) await legacy.remove(ctx, lifted)
247
193
  }
248
194
  return recordWireRow(kind, recordId, columns, createdAt, stamps.updatedAt)
249
195
  }
@@ -256,14 +202,8 @@ export interface RecordFunctions {
256
202
  remove: RegisteredMutation<'public', Record<string, unknown>, Promise<void>>
257
203
  }
258
204
 
259
- export interface RecordFunctionsOptions {
260
- /** TOLERANT READ (marked) — see {@link LegacyRecordSource}. Only a kind that moved declares one. */
261
- legacy?: LegacyRecordSource
262
- }
263
-
264
205
  /** The public CRUD functions of one record kind — see the module header. */
265
- export function defineRecordFunctions(kind: AnyRecordKind, options: RecordFunctionsOptions = {}): RecordFunctions {
266
- const { legacy } = options
206
+ export function defineRecordFunctions(kind: AnyRecordKind): RecordFunctions {
267
207
  const idArg = { [kind.idKey]: v.string() }
268
208
  const columnArgs = Object.fromEntries(
269
209
  [...Object.keys(kind.fields), 'meta', 'metaPatch'].map((column) => [column, v.optional(v.any())]),
@@ -274,14 +214,10 @@ export function defineRecordFunctions(kind: AnyRecordKind, options: RecordFuncti
274
214
  handler: async (ctx, args: Record<string, unknown>) => {
275
215
  await requireCapability(ctx, kind.access.write)
276
216
  const { [kind.idKey]: recordId, createdAt, updatedAt, ...write } = args
277
- return saveRecord(
278
- ctx,
279
- kind,
280
- recordId as string,
281
- write,
282
- { createdAt: createdAt as string, updatedAt: updatedAt as string },
283
- legacy,
284
- )
217
+ return saveRecord(ctx, kind, recordId as string, write, {
218
+ createdAt: createdAt as string,
219
+ updatedAt: updatedAt as string,
220
+ })
285
221
  },
286
222
  })
287
223
 
@@ -289,7 +225,7 @@ export function defineRecordFunctions(kind: AnyRecordKind, options: RecordFuncti
289
225
  args: idArg,
290
226
  handler: async (ctx, args: Record<string, unknown>) => {
291
227
  await requireCapability(ctx, kind.access.read)
292
- return getRecordWire(ctx, kind, args[kind.idKey] as string, legacy)
228
+ return getRecordWire(ctx, kind, args[kind.idKey] as string)
293
229
  },
294
230
  })
295
231
 
@@ -304,7 +240,7 @@ export function defineRecordFunctions(kind: AnyRecordKind, options: RecordFuncti
304
240
  handler: async (ctx, args: RecordQuery) => {
305
241
  await requireCapability(ctx, kind.access.read)
306
242
  const resolved = rethrowAsConvex(() => resolveRecordQuery(kind, args))
307
- return listRecordWire(ctx, kind, resolved, legacy)
243
+ return listRecordWire(ctx, kind, resolved)
308
244
  },
309
245
  })
310
246
 
@@ -315,9 +251,6 @@ export function defineRecordFunctions(kind: AnyRecordKind, options: RecordFuncti
315
251
  const recordId = args[kind.idKey] as string
316
252
  const doc = await findRecordDoc(ctx, kind, recordId)
317
253
  if (doc) await ctx.db.delete(doc._id)
318
- // TOLERANT READ (marked): a legacy row of the same id goes too, or it would resurface.
319
- const old = legacy ? await legacy.find(ctx, recordId) : null
320
- if (old && legacy) await legacy.remove(ctx, old)
321
254
  },
322
255
  })
323
256
 
@@ -1,18 +1,7 @@
1
- import { internalMutationGeneric } from 'convex/server'
2
- import type { MutationBuilder } from 'convex/server'
3
- import {
4
- PersistenceValidationError,
5
- recordColumnsOf,
6
- recordSlots,
7
- redirectRecordKind,
8
- resolveRecordQuery,
9
- resolveRecordSave,
10
- } from '@elytracms/core/persistence'
1
+ import { recordEntryFromWire, redirectRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
11
2
  import type { RedirectEntry } from '@elytracms/core/persistence'
12
- import type { DataModel, MutationCtx, QueryCtx } from './functions'
13
- import { defineRecordFunctions, findRecordDoc, listRecordWire } from './record-functions'
14
- import type { LegacyRecord, LegacyRecordSource } from './record-functions'
15
- import { recordEntryFromWire } from '@elytracms/core/persistence'
3
+ import type { QueryCtx } from './functions'
4
+ import { defineRecordFunctions, listRecordWire } from './record-functions'
16
5
 
17
6
  /**
18
7
  * Editorial redirects persistence (EC-303) — since EC-616 the redirect RECORD KIND over
@@ -22,112 +11,18 @@ import { recordEntryFromWire } from '@elytracms/core/persistence'
22
11
  * `export * from '@elytracms/studio/convex/redirects'` line are unchanged. Content-gated
23
12
  * (a viewer reads, an editor writes), NOT admin-only — redirects are content.
24
13
  *
25
- * ## The move from the `redirects` table (stored data — KON and NK hold real redirects)
26
- *
27
- * The EC-587 precedent, with no gap:
28
- *
29
- * - **writes go to `records`** (kind `redirect`); nothing writes the legacy table again;
30
- * - **reads union the legacy rows no record shadows** — the TOLERANT READ below, marked:
31
- * a deployment with these functions pushed and the migration NOT yet run serves the
32
- * same redirects it served before (`list`, `get`, the delivery snapshot);
33
- * - a save of a legacy-only redirect lifts it (record from its columns, legacy row
34
- * deleted, one transaction); a remove deletes both;
35
- * - **`redirects:migrateLegacy`** moves every legacy row in one idempotent internal
36
- * mutation and reports counts — run per deployment (both consumers + the prod harness:
37
- * owner-run); the legacy table and this tolerant read are dropped together once it is
38
- * reported run everywhere (a gated item, like EC-587's alt column).
14
+ * The legacy `redirects` table, its tolerant read and its one-time move
15
+ * (`redirects:migrateLegacy`) are deleted (EC-708), after every deployment's scan read 0
16
+ * legacy rows: `records` is the one home.
39
17
  */
40
-
41
- type LegacyRedirectDoc = DataModel['redirects']['document']
42
-
43
- /** TOLERANT READ (marked) — the legacy `redirects` table as the kind's legacy source. */
44
- const legacyRedirects: LegacyRecordSource = {
45
- find: async (ctx, recordId) => {
46
- const row = await ctx.db
47
- .query('redirects')
48
- .withIndex('by_redirect', (q) => q.eq('redirectId', recordId))
49
- .first()
50
- return row ? legacyRecord(row) : null
51
- },
52
- list: async (ctx) => (await ctx.db.query('redirects').withIndex('by_redirect').collect()).map(legacyRecord),
53
- remove: async (ctx, record) => {
54
- await ctx.db.delete(record.handle as LegacyRedirectDoc['_id'])
55
- },
56
- }
57
-
58
- function legacyRecord(row: LegacyRedirectDoc): LegacyRecord {
59
- return {
60
- recordId: row.redirectId,
61
- columns: recordColumnsOf(redirectRecordKind, row),
62
- createdAt: row.createdAt,
63
- updatedAt: row.updatedAt,
64
- handle: row._id,
65
- }
66
- }
67
-
68
- export const { save, get, list, remove } = defineRecordFunctions(redirectRecordKind, { legacy: legacyRedirects })
18
+ export const { save, get, list, remove } = defineRecordFunctions(redirectRecordKind)
69
19
 
70
20
  /**
71
- * Every stored redirect of this deployment (records + the tolerant legacy read), ordered
72
- * by `from` — the ONE server-side reader; the delivery snapshot projects it through
73
- * `readLiveRedirects` (EC-590 / EC-627 / EC-628: the one live-redirect rule).
21
+ * Every stored redirect of this deployment, ordered by `from` — the ONE server-side
22
+ * reader; the delivery snapshot projects it through `readLiveRedirects` (EC-590 / EC-627
23
+ * / EC-628: the one live-redirect rule).
74
24
  */
75
25
  export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Promise<RedirectEntry[]> {
76
- const rows = await listRecordWire(ctx, redirectRecordKind, resolveRecordQuery(redirectRecordKind), legacyRedirects)
26
+ const rows = await listRecordWire(ctx, redirectRecordKind, resolveRecordQuery(redirectRecordKind))
77
27
  return rows.map((row) => recordEntryFromWire(redirectRecordKind, row, projectId))
78
28
  }
79
-
80
- /**
81
- * EC-616 — move every legacy `redirects` row into `records` (kind `redirect`), the
82
- * redirect move's migration. Per row: a record of the same id already exists (a save
83
- * lifted it, or an earlier run) → the legacy row is dropped as shadowed; otherwise the
84
- * row's columns are written as a record CREATE through the one rule (`resolveRecordSave`
85
- * — a legacy row the current schema refuses is REPORTED and left where it is, still
86
- * served by the tolerant read, never dropped silently) and the legacy row deleted.
87
- * Idempotent: a second run scans nothing.
88
- *
89
- * Internal: an admin runs it from the CLI, once per deployment —
90
- * `npx convex run redirects:migrateLegacy` (no args). Built on the RAW internal-mutation
91
- * builder, deliberately outside the write-chokepoint triggers: the served redirect set is
92
- * identical before and after by construction (the tolerant read), so the move needs no
93
- * revalidation — through the triggers it would schedule two sweeps per row (an insert
94
- * into `records`, a delete from `redirects`). One transaction: Convex bounds it at 8192
95
- * written rows, far above any editorial redirect set (KON's import holds 192).
96
- */
97
- export const migrateLegacy = (internalMutationGeneric as MutationBuilder<DataModel, 'internal'>)({
98
- args: {},
99
- handler: async (ctx) => {
100
- const legacy = await ctx.db.query('redirects').withIndex('by_redirect').collect()
101
- const moved: string[] = []
102
- const shadowed: string[] = []
103
- const refused: Array<{ redirectId: string; message: string }> = []
104
- for (const row of legacy) {
105
- const record = legacyRecord(row)
106
- if (await findRecordDoc(ctx as unknown as MutationCtx, redirectRecordKind, record.recordId)) {
107
- await ctx.db.delete(row._id)
108
- shadowed.push(record.recordId)
109
- continue
110
- }
111
- let columns: Record<string, unknown>
112
- try {
113
- columns = resolveRecordSave(redirectRecordKind, record.recordId, undefined, record.columns)
114
- } catch (error) {
115
- if (!(error instanceof PersistenceValidationError)) throw error
116
- refused.push({ redirectId: record.recordId, message: error.message })
117
- continue
118
- }
119
- const slots = recordSlots(redirectRecordKind, columns, record.createdAt)
120
- await ctx.db.insert('records', {
121
- kind: redirectRecordKind.kind,
122
- recordId: record.recordId,
123
- data: columns,
124
- at: slots.at,
125
- createdAt: record.createdAt,
126
- updatedAt: record.updatedAt,
127
- })
128
- await ctx.db.delete(row._id)
129
- moved.push(record.recordId)
130
- }
131
- return { scanned: legacy.length, moved: moved.length, shadowed: shadowed.length, refused, movedIds: moved }
132
- },
133
- })
package/convex/schema.ts CHANGED
@@ -239,10 +239,10 @@ export default defineSchema({
239
239
  origin: v.optional(v.string()),
240
240
  // EC-585: which mutation recorded the row — 'publish' (`publishDocument`, the only
241
241
  // kind `publishDocumentVersion` pins) or 'snapshot' (`saveDocumentVersion`: a restore
242
- // point). Optional ONLY for rows recorded before EC-585 (read through
243
- // `isPinnableVersion`, which keeps a kind-less `label: 'published'` row pinnable). A
244
- // top-level scalar beside the folded snapshot: it adds no nesting depth.
245
- kind: v.optional(v.union(v.literal('publish'), v.literal('snapshot'))),
242
+ // point). Required (EC-708: every deployment stamped and read 0 kind-less rows first;
243
+ // the push refuses a row without it). A top-level scalar beside the folded snapshot: it
244
+ // adds no nesting depth.
245
+ kind: v.union(v.literal('publish'), v.literal('snapshot')),
246
246
  createdAt: v.string(),
247
247
  })
248
248
  .index('by_doc', ['collection', 'docId'])
@@ -259,7 +259,6 @@ export default defineSchema({
259
259
  byteSize: v.number(),
260
260
  width: v.optional(v.number()),
261
261
  height: v.optional(v.number()),
262
- alt: v.optional(v.string()),
263
262
  /** Editable display title (EC-152; persisted for parity with in-memory — EC-153). */
264
263
  title: v.optional(v.string()),
265
264
  storageKey: v.string(),
@@ -306,24 +305,6 @@ export default defineSchema({
306
305
  .index('by_storage', ['storageKey'])
307
306
  .index('by_thumb_storage', ['thumb.storageKey']),
308
307
 
309
- /**
310
- * Editorial redirects (EC-303) — 301/302 forwards an editor owns, stored as
311
- * DATA (not config) so they change dynamically without a deploy. The delivery
312
- * snapshot projects enabled entries onto the routing `RedirectRecord` the
313
- * catch-all resolves; static/infra redirects stay native (next.config/vercel).
314
- */
315
- redirects: defineTable({
316
- redirectId: v.string(),
317
- from: v.string(),
318
- to: v.string(),
319
- permanent: v.boolean(),
320
- enabled: v.boolean(),
321
- // Schema-driven additive-field bag (EC-311 Tier 2) — see the `assets.meta` bag.
322
- meta: v.optional(v.any()),
323
- createdAt: v.string(),
324
- updatedAt: v.string(),
325
- }).index('by_redirect', ['redirectId']),
326
-
327
308
  /**
328
309
  * The generic RECORD STORE (EC-616) — one row per record of a DECLARED record kind
329
310
  * (`defineRecordKind` in `@elytracms/core/persistence`): the engine's own kinds and