@elytracms/studio 0.0.101 → 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 (81) hide show
  1. package/convex/asset-meta-patch.test.ts +7 -27
  2. package/convex/assets.ts +6 -41
  3. package/convex/cms.ts +2 -29
  4. package/convex/delivery.ts +2 -2
  5. package/convex/record-functions.ts +15 -82
  6. package/convex/redirects.ts +11 -116
  7. package/convex/schema.ts +4 -23
  8. package/convex/triggers.ts +7 -14
  9. package/dist/components/ai/ai-chat-panel.js +8 -5
  10. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  11. package/dist/components/ai/translate-skipped.js +1 -0
  12. package/dist/components/ai/translate-skipped.js.map +1 -1
  13. package/dist/components/assets/asset-detail.js +1 -2
  14. package/dist/components/assets/asset-detail.js.map +1 -1
  15. package/dist/components/assets/asset-metadata.d.ts +2 -9
  16. package/dist/components/assets/asset-metadata.js +3 -10
  17. package/dist/components/assets/asset-metadata.js.map +1 -1
  18. package/dist/components/assets/asset-search.js +1 -1
  19. package/dist/components/assets/asset-search.js.map +1 -1
  20. package/dist/components/assets/asset-selection.js +2 -2
  21. package/dist/components/assets/asset-selection.js.map +1 -1
  22. package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
  23. package/dist/components/assets/assets-collection-view-source.js +2 -3
  24. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  25. package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
  26. package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
  27. package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
  28. package/dist/components/builder/composition-field-editor.js +3 -3
  29. package/dist/components/builder/composition-field-editor.js.map +1 -1
  30. package/dist/components/builder/reuse-context.d.ts +2 -1
  31. package/dist/components/builder/reuse-context.js +5 -20
  32. package/dist/components/builder/reuse-context.js.map +1 -1
  33. package/dist/components/builder/reuse.d.ts +18 -11
  34. package/dist/components/builder/reuse.js +12 -14
  35. package/dist/components/builder/reuse.js.map +1 -1
  36. package/dist/components/cms/asset-field.js +1 -3
  37. package/dist/components/cms/asset-field.js.map +1 -1
  38. package/dist/components/cms/cell.d.ts +2 -2
  39. package/dist/components/cms/cell.js +8 -18
  40. package/dist/components/cms/cell.js.map +1 -1
  41. package/dist/components/cms/detail-editor.js +2 -1
  42. package/dist/components/cms/detail-editor.js.map +1 -1
  43. package/dist/components/cms/field-control.js +13 -1
  44. package/dist/components/cms/field-control.js.map +1 -1
  45. package/dist/lib/ai/chat-context.d.ts +6 -2
  46. package/dist/lib/ai/chat-context.js +11 -5
  47. package/dist/lib/ai/chat-context.js.map +1 -1
  48. package/dist/lib/ai/knowledge.d.ts +9 -9
  49. package/dist/lib/ai/knowledge.js +22 -0
  50. package/dist/lib/ai/knowledge.js.map +1 -1
  51. package/dist/lib/ai/tools.d.ts +8 -1
  52. package/dist/lib/ai/tools.js +33 -15
  53. package/dist/lib/ai/tools.js.map +1 -1
  54. package/dist/lib/ai/translate-document.d.ts +8 -1
  55. package/dist/lib/ai/translate-document.js +20 -5
  56. package/dist/lib/ai/translate-document.js.map +1 -1
  57. package/dist/lib/ai/update-fields.js +5 -12
  58. package/dist/lib/ai/update-fields.js.map +1 -1
  59. package/dist/lib/ai/writer-form.d.ts +61 -0
  60. package/dist/lib/ai/writer-form.js +218 -27
  61. package/dist/lib/ai/writer-form.js.map +1 -1
  62. package/dist/lib/assets/upload.d.ts +1 -2
  63. package/dist/lib/assets/upload.js +1 -2
  64. package/dist/lib/assets/upload.js.map +1 -1
  65. package/dist/lib/engine-version.d.ts +1 -1
  66. package/dist/lib/engine-version.js +1 -1
  67. package/dist/lib/engine-version.js.map +1 -1
  68. package/dist/lib/i18n/messages.de.js +9 -1
  69. package/dist/lib/i18n/messages.de.js.map +1 -1
  70. package/dist/lib/i18n/messages.en.d.ts +7 -0
  71. package/dist/lib/i18n/messages.en.js +11 -1
  72. package/dist/lib/i18n/messages.en.js.map +1 -1
  73. package/dist/lib/operations/cms-persistence.d.ts +20 -10
  74. package/dist/lib/operations/cms-persistence.js +11 -3
  75. package/dist/lib/operations/cms-persistence.js.map +1 -1
  76. package/dist/lib/persistence/convex-adapter.js +7 -2
  77. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  78. package/package.json +3 -3
  79. package/convex/asset-alt-migration.test.ts +0 -127
  80. package/convex/redirect-migration.test.ts +0 -205
  81. package/convex/version-kind-stamping.test.ts +0 -118
@@ -1,127 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { convexTest } from 'convex-test'
3
- import { makeFunctionReference } from 'convex/server'
4
- import { measureMaxDepth } from '@elytracms/core/cms-core'
5
- import { InMemoryAssetRepository, assetAlt, liftLegacyAssetAlts } from '@elytracms/core/persistence'
6
- import type { AssetRecord } from '@elytracms/core/persistence'
7
- import schema from './schema'
8
-
9
- // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
10
- // revalidations queued (rows written, the `webhooks:deliver` fetch never fired).
11
- beforeEach(() => {
12
- vi.useFakeTimers()
13
- })
14
- afterEach(() => {
15
- vi.useRealTimers()
16
- })
17
-
18
- /**
19
- * EC-587 — the one-shot migration of the legacy `alt` column into `meta.alt`, its one
20
- * home: the REAL internal mutation (`assets:liftLegacyAlt`, the line the operator runs
21
- * with `npx convex run`) on convex-test's backend, and the in-memory runner
22
- * (`liftLegacyAssetAlts`) held to the same expectations over the same rows.
23
- */
24
-
25
- const modules = {
26
- ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
27
- './_generated/api.ts': async () => ({}),
28
- }
29
-
30
- const liftLegacyAlt = makeFunctionReference<'mutation'>('assets:liftLegacyAlt')
31
-
32
- const STAMP = '2026-10-26T00:00:00.000Z'
33
-
34
- /** The four shapes a stored asset row can hold its alt in. */
35
- const ROWS = [
36
- { assetId: 'column_only', alt: 'Harbour' },
37
- { assetId: 'both', alt: 'stale column', meta: { alt: 'Meta wins', credit: 'Ada' } },
38
- { assetId: 'meta_only', meta: { alt: 'Already home' } },
39
- { assetId: 'none', meta: { credit: 'Jo' } },
40
- ] as const
41
-
42
- function row(input: (typeof ROWS)[number]) {
43
- return {
44
- filename: `${input.assetId}.jpg`,
45
- contentType: 'image/jpeg',
46
- byteSize: 1,
47
- storageKey: `k_${input.assetId}`,
48
- createdAt: STAMP,
49
- updatedAt: STAMP,
50
- ...input,
51
- }
52
- }
53
-
54
- /** What every reader sees BEFORE the lift — the migration must not change it. */
55
- const READ_BEFORE = { column_only: 'Harbour', both: 'Meta wins', meta_only: 'Already home', none: null }
56
-
57
- const EXPECTED_META = {
58
- column_only: { alt: 'Harbour' },
59
- both: { alt: 'Meta wins', credit: 'Ada' },
60
- meta_only: { alt: 'Already home' },
61
- none: { credit: 'Jo' },
62
- }
63
-
64
- async function seededBackend() {
65
- const t = convexTest(schema, modules)
66
- await t.run(async (ctx) => {
67
- for (const input of ROWS) await ctx.db.insert('assets', row(input) as never)
68
- })
69
- return t
70
- }
71
-
72
- async function storedRows(t: Awaited<ReturnType<typeof seededBackend>>) {
73
- return t.run(async (ctx) => {
74
- const rows = await ctx.db.query('assets').collect()
75
- return Object.fromEntries(rows.map((r) => [r.assetId, { alt: r.alt, meta: r.meta }]))
76
- })
77
- }
78
-
79
- describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', () => {
80
- it('lifts the column into meta.alt (meta.alt wins when both), unsets it, and keeps every alt reading', async () => {
81
- const t = await seededBackend()
82
- const before = await storedRows(t)
83
- expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
84
-
85
- const report = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
86
- expect(report.scanned).toBe(4)
87
- expect([...report.lifted].sort()).toEqual(['both', 'column_only'])
88
-
89
- const after = await storedRows(t)
90
- for (const [id, stored] of Object.entries(after)) {
91
- expect(stored.alt, `${id}: the column is gone`).toBeUndefined()
92
- expect(stored.meta, `${id}: meta`).toEqual(EXPECTED_META[id as keyof typeof EXPECTED_META])
93
- // convex-test enforces no nesting depth — the stored shape asserts it itself.
94
- expect(measureMaxDepth(stored.meta).depth).toBeLessThanOrEqual(16)
95
- }
96
- // The lift changes no reading: every alt is what it was.
97
- expect(Object.fromEntries(Object.entries(after).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
98
- })
99
-
100
- it('is idempotent: a second run lifts nothing and writes nothing', async () => {
101
- const t = await seededBackend()
102
- await t.mutation(liftLegacyAlt, {})
103
- const once = await storedRows(t)
104
- const scheduledAfterFirst = await t.run(async (ctx) => (await ctx.db.system.query('_scheduled_functions').collect()).length)
105
- const again = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
106
- expect(again).toEqual({ scanned: 4, lifted: [] })
107
- expect(await storedRows(t)).toEqual(once)
108
- const scheduledAfterSecond = await t.run(async (ctx) => (await ctx.db.system.query('_scheduled_functions').collect()).length)
109
- expect(scheduledAfterSecond).toBe(scheduledAfterFirst)
110
- })
111
-
112
- it('the in-memory runner does the same to the same rows (the playground / headless equivalent)', async () => {
113
- const records: AssetRecord[] = ROWS.map((input) => {
114
- const { assetId, ...rest } = row(input)
115
- return { ...rest, id: assetId, projectId: 'p' } as AssetRecord
116
- })
117
- const repo = new InMemoryAssetRepository({ seed: records })
118
- const report = await liftLegacyAssetAlts(repo, 'p')
119
- expect(report.scanned).toBe(4)
120
- expect([...report.lifted].sort()).toEqual(['both', 'column_only'])
121
- const after = await repo.listAssets('p')
122
- expect(Object.fromEntries(after.map((r) => [r.id, { alt: r.alt, meta: r.meta }]))).toEqual(
123
- Object.fromEntries(Object.entries(EXPECTED_META).map(([id, meta]) => [id, { alt: undefined, meta }])),
124
- )
125
- expect((await liftLegacyAssetAlts(repo, 'p')).lifted).toEqual([])
126
- })
127
- })
@@ -1,205 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { convexTest } from 'convex-test'
3
- import { makeFunctionReference } from 'convex/server'
4
- import schema from './schema'
5
-
6
- // convex-test fires `runAfter(0)` on a REAL timer; fake timers keep any scheduled
7
- // revalidation queued (rows written, the `webhooks:deliver` fetch never fired).
8
- beforeEach(() => {
9
- vi.useFakeTimers()
10
- })
11
- afterEach(() => {
12
- vi.useRealTimers()
13
- })
14
-
15
- /**
16
- * EC-616 — the redirect move from the `redirects` table into `records` (kind `redirect`)
17
- * is a STORED-DATA move (both production consumers hold real redirects), done the EC-587
18
- * way: writes to the new home, a marked tolerant read of the old one, an idempotent
19
- * per-deployment migration. These run the REAL functions on convex-test over legacy rows
20
- * inserted as a pre-EC-616 deployment holds them, and pin "no gap": a deployment with the
21
- * new functions pushed and the migration NOT yet run serves exactly what it served before.
22
- */
23
-
24
- const modules = {
25
- ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
26
- './_generated/api.ts': async () => ({}),
27
- }
28
-
29
- const listRedirects = makeFunctionReference<'query'>('redirects:list')
30
- const getRedirect = makeFunctionReference<'query'>('redirects:get')
31
- const saveRedirect = makeFunctionReference<'mutation'>('redirects:save')
32
- const removeRedirect = makeFunctionReference<'mutation'>('redirects:remove')
33
- const migrateLegacy = makeFunctionReference<'mutation'>('redirects:migrateLegacy')
34
- const snapshot = makeFunctionReference<'query'>('delivery:snapshot')
35
-
36
- const STAMP = '2026-08-01T00:00:00.000Z'
37
- const LATER = '2026-10-26T00:00:00.000Z'
38
-
39
- /** Rows as a pre-EC-616 deployment stores them in the legacy `redirects` table. */
40
- const LEGACY = [
41
- { redirectId: 'r_spring', from: '/spring', to: '/offers', permanent: true, enabled: true },
42
- { redirectId: 'r_paused', from: '/paused', to: '/x', permanent: false, enabled: false, meta: { note: 'later' } },
43
- { redirectId: 'r_blog', from: '/blog-old', to: '/blog', permanent: true, enabled: true },
44
- // Stored before EC-577 refused it: a meta key naming a column. The current schema
45
- // refuses this row as a create, so the migration must REPORT it, never drop it.
46
- { redirectId: 'r_shadow', from: '/shadow', to: '/real', permanent: true, enabled: true, meta: { to: '/fake' } },
47
- ] as const
48
-
49
- async function legacyDeployment(configuredRevalidation = false) {
50
- const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
51
- if (configuredRevalidation) {
52
- env['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
53
- env['CANVAS_REVALIDATE_SECRET'] = 'ec616-secret'
54
- }
55
- const t = convexTest(schema, modules)
56
- await t.run(async (ctx) => {
57
- await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
58
- for (const row of LEGACY) await ctx.db.insert('redirects', { ...row, createdAt: STAMP, updatedAt: STAMP })
59
- })
60
- const asEditor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
61
- return { t, asEditor }
62
- }
63
-
64
- afterEach(() => {
65
- const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
66
- delete env['CANVAS_REVALIDATE_ENDPOINT']
67
- delete env['CANVAS_REVALIDATE_SECRET']
68
- })
69
-
70
- /** What the pre-EC-616 functions returned for the legacy rows: each row flat, ordered by `from`. */
71
- const SERVED_BEFORE = [...LEGACY]
72
- .sort((a, b) => (a.from < b.from ? -1 : 1))
73
- .map((row) => ({ ...row, createdAt: STAMP, updatedAt: STAMP }))
74
-
75
- /** What the pre-EC-616 snapshot served: the enabled rows as routing records. */
76
- const LIVE_BEFORE = LEGACY.filter((row) => row.enabled).map((row) => ({
77
- id: row.redirectId,
78
- from: row.from,
79
- to: row.to,
80
- permanent: row.permanent,
81
- }))
82
-
83
- const byId = (a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)
84
-
85
- async function counts(t: Awaited<ReturnType<typeof legacyDeployment>>['t']) {
86
- return t.run(async (ctx) => ({
87
- legacy: (await ctx.db.query('redirects').collect()).length,
88
- records: (await ctx.db.query('records').collect()).filter((row) => row.kind === 'redirect').length,
89
- }))
90
- }
91
-
92
- describe('the redirect move: no gap before the migration runs (EC-616)', () => {
93
- it('list, get and the delivery snapshot serve the legacy rows exactly as before', async () => {
94
- const { asEditor } = await legacyDeployment()
95
- expect(await asEditor.query(listRedirects, {})).toEqual(SERVED_BEFORE)
96
- expect(await asEditor.query(getRedirect, { redirectId: 'r_paused' })).toEqual(
97
- SERVED_BEFORE.find((row) => row.redirectId === 'r_paused'),
98
- )
99
- const served = (await asEditor.query(snapshot, { projectId: 'demo' })) as { redirects: { id: string }[] }
100
- expect([...served.redirects].sort(byId)).toEqual([...LIVE_BEFORE].sort(byId))
101
- })
102
-
103
- it('a save of a legacy-only redirect lifts it: an edit, not a create; the legacy row goes', async () => {
104
- const { t, asEditor } = await legacyDeployment()
105
- // An edit that leaves `enabled`/`permanent` absent keeps the STORED (legacy) values.
106
- const saved = await asEditor.mutation(saveRedirect, {
107
- redirectId: 'r_paused',
108
- to: '/y',
109
- createdAt: LATER,
110
- updatedAt: LATER,
111
- })
112
- expect(saved).toEqual({
113
- redirectId: 'r_paused',
114
- from: '/paused',
115
- to: '/y',
116
- permanent: false,
117
- enabled: false,
118
- meta: { note: 'later' },
119
- createdAt: STAMP,
120
- updatedAt: LATER,
121
- })
122
- expect(await counts(t)).toEqual({ legacy: 3, records: 1 })
123
- expect((await asEditor.query(listRedirects, {})) as unknown[]).toHaveLength(4)
124
- })
125
-
126
- it('a remove of a legacy-only redirect deletes it (it never resurfaces)', async () => {
127
- const { t, asEditor } = await legacyDeployment()
128
- await asEditor.mutation(removeRedirect, { redirectId: 'r_blog' })
129
- expect(await asEditor.query(getRedirect, { redirectId: 'r_blog' })).toBeNull()
130
- expect(await counts(t)).toEqual({ legacy: 3, records: 0 })
131
- })
132
- })
133
-
134
- describe('redirects:migrateLegacy (EC-616)', () => {
135
- it('moves every valid legacy row, reports the refused one, and serves the same set after', async () => {
136
- const { t, asEditor } = await legacyDeployment()
137
- const first = await t.mutation(migrateLegacy, {})
138
- expect(first).toEqual({
139
- scanned: 4,
140
- moved: 3,
141
- shadowed: 0,
142
- refused: [{ redirectId: 'r_shadow', message: expect.stringMatching(/meta may not use a column name/) }],
143
- movedIds: ['r_blog', 'r_paused', 'r_spring'],
144
- })
145
- expect(await counts(t)).toEqual({ legacy: 1, records: 3 })
146
- // The served set is unchanged by the move — the refused row still serves (tolerant read).
147
- expect(await asEditor.query(listRedirects, {})).toEqual(SERVED_BEFORE)
148
- const served = (await asEditor.query(snapshot, { projectId: 'demo' })) as { redirects: { id: string }[] }
149
- expect([...served.redirects].sort(byId)).toEqual([...LIVE_BEFORE].sort(byId))
150
- })
151
-
152
- it('is idempotent: a second run moves nothing', async () => {
153
- const { t } = await legacyDeployment()
154
- await t.mutation(migrateLegacy, {})
155
- expect(await t.mutation(migrateLegacy, {})).toMatchObject({ scanned: 1, moved: 0, shadowed: 0 })
156
- expect(await counts(t)).toEqual({ legacy: 1, records: 3 })
157
- })
158
-
159
- it('drops a legacy row a record already shadows (a save lifted it meanwhile)', async () => {
160
- const { t, asEditor } = await legacyDeployment()
161
- // Simulate the race the transaction cannot have: a record and its legacy row both present.
162
- await t.run(async (ctx) => {
163
- await ctx.db.insert('redirects', {
164
- redirectId: 'r_twice',
165
- from: '/twice',
166
- to: '/a',
167
- permanent: true,
168
- enabled: true,
169
- createdAt: STAMP,
170
- updatedAt: STAMP,
171
- })
172
- })
173
- await asEditor.mutation(saveRedirect, { redirectId: 'r_twice', from: '/twice', to: '/b', createdAt: LATER, updatedAt: LATER })
174
- await t.run(async (ctx) => {
175
- await ctx.db.insert('redirects', {
176
- redirectId: 'r_twice',
177
- from: '/twice',
178
- to: '/a',
179
- permanent: true,
180
- enabled: true,
181
- createdAt: STAMP,
182
- updatedAt: STAMP,
183
- })
184
- })
185
- // The record wins every read while both exist…
186
- expect(await asEditor.query(getRedirect, { redirectId: 'r_twice' })).toMatchObject({ to: '/b' })
187
- // …and the migration drops the shadowed legacy row instead of overwriting the record.
188
- expect(await t.mutation(migrateLegacy, {})).toMatchObject({ shadowed: 1 })
189
- expect(await asEditor.query(getRedirect, { redirectId: 'r_twice' })).toMatchObject({ to: '/b' })
190
- })
191
-
192
- it('schedules no revalidation: the served set is identical before and after', async () => {
193
- const { t } = await legacyDeployment(true)
194
- await t.mutation(migrateLegacy, {})
195
- const scheduled = await t.run(async (ctx) => ctx.db.system.query('_scheduled_functions').collect())
196
- expect(scheduled).toEqual([])
197
- })
198
-
199
- it('control: a redirect SAVE on the same configured deployment does schedule one', async () => {
200
- const { t, asEditor } = await legacyDeployment(true)
201
- await asEditor.mutation(saveRedirect, { redirectId: 'r_new', from: '/n', to: '/m', createdAt: LATER, updatedAt: LATER })
202
- const scheduled = await t.run(async (ctx) => ctx.db.system.query('_scheduled_functions').collect())
203
- expect(scheduled.map((row) => (row.args as [{ event: unknown }])[0].event)).toEqual([{ kind: 'redirects' }])
204
- })
205
- })
@@ -1,118 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { convexTest } from 'convex-test'
3
- import { makeFunctionReference } from 'convex/server'
4
- import { foldDocument } from '@elytracms/core/cms-core'
5
- import type { CmsDocument } from '@elytracms/core/cms-core'
6
- import { VERSION_KIND_STAMP_BOUND, isPinnableVersion, scanStoredShapes } from '@elytracms/core/persistence'
7
- import schema from './schema'
8
-
9
- beforeEach(() => {
10
- vi.useFakeTimers()
11
- })
12
- afterEach(() => {
13
- vi.useRealTimers()
14
- })
15
-
16
- /**
17
- * EC-707 — the EC-585 kind stamping, the REAL internal mutation (`cms:stampVersionKinds`,
18
- * the line an admin runs with `npx convex run`) on convex-test's backend: planted kind-less
19
- * rows of each label are stamped with exactly the kind `isPinnableVersion` reads them as,
20
- * a row that names a kind is untouched, pinnability is unchanged for every row, a second
21
- * run stamps 0, and above the documented bound the run refuses whole.
22
- */
23
-
24
- const modules = {
25
- ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
26
- './_generated/api.ts': async () => ({}),
27
- }
28
- const stampVersionKinds = makeFunctionReference<'mutation'>('cms:stampVersionKinds')
29
-
30
- const STAMP = '2026-10-29T00:00:00.000Z'
31
- const snapshot = (id: string) =>
32
- foldDocument({ collection: 'page', id, values: { title: id } } as unknown as CmsDocument)
33
-
34
- /** The planted rows: kind-less of each label, and two that name a kind. */
35
- const PLANTED = [
36
- { docId: 'a', version: 1, label: 'published' },
37
- { docId: 'a', version: 2, label: 'pre-restore' },
38
- { docId: 'a', version: 3, label: 'AI snapshot' },
39
- { docId: 'b', version: 1 },
40
- { docId: 'b', version: 2, label: 'published', kind: 'snapshot' as const },
41
- { docId: 'b', version: 3, label: 'pre-restore', kind: 'publish' as const },
42
- ]
43
-
44
- async function plant(
45
- rows: ReadonlyArray<{ docId: string; version: number; label?: string; kind?: 'publish' | 'snapshot' }>,
46
- ) {
47
- const t = convexTest(schema, modules)
48
- await t.run(async (ctx) => {
49
- for (const row of rows) {
50
- await ctx.db.insert('documentVersions', {
51
- collection: 'page',
52
- snapshot: snapshot(row.docId),
53
- createdAt: STAMP,
54
- ...row,
55
- })
56
- }
57
- })
58
- return t
59
- }
60
-
61
- const stored = (t: Awaited<ReturnType<typeof plant>>) =>
62
- t.run(async (ctx) =>
63
- (await ctx.db.query('documentVersions').collect()).sort((x, y) =>
64
- x.docId + x.version < y.docId + y.version ? -1 : 1,
65
- ),
66
- )
67
-
68
- describe('EC-707 — cms:stampVersionKinds stamps what isPinnableVersion infers, once', () => {
69
- it('stamps every kind-less row by its label, leaves a kinded row alone, keeps pinnability; a second run stamps 0', async () => {
70
- const t = await plant(PLANTED)
71
- const before = await stored(t)
72
- expect(before.filter((row) => row.kind === undefined)).toHaveLength(4)
73
-
74
- expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 4 })
75
- const after = await stored(t)
76
- expect(after.map((row) => `${row.docId}@v${row.version} ${row.kind}`)).toEqual([
77
- 'a@v1 publish',
78
- 'a@v2 snapshot',
79
- 'a@v3 snapshot',
80
- 'b@v1 snapshot',
81
- 'b@v2 snapshot', // named a kind: untouched (its label says published, its kind wins)
82
- 'b@v3 publish',
83
- ])
84
- // The migration preserves meaning: every row pins exactly as it did kind-less.
85
- expect(after.map(isPinnableVersion)).toEqual(before.map(isPinnableVersion))
86
- // Nothing but `kind` was written: label, snapshot (still folded), createdAt as planted.
87
- for (const [index, row] of after.entries()) {
88
- const { kind: _k, ...rest } = row
89
- const { kind: _b, ...was } = before[index]!
90
- expect(rest).toEqual(was)
91
- }
92
- // The at-rest scan now reads 0 kind-less rows here.
93
- const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: after }, { collections: [] })
94
- expect(scan.byShape['kind-less-version']).toBe(0)
95
-
96
- expect(await t.mutation(stampVersionKinds, {})).toEqual({ scanned: 6, stamped: 0 })
97
- })
98
-
99
- it('CONTROL: before the stamping the at-rest scan counts the kind-less rows (the scan can see them)', async () => {
100
- const t = await plant(PLANTED)
101
- const scan = scanStoredShapes({ cmsDocuments: [], documentVersions: await stored(t) }, { collections: [] })
102
- expect(scan.byShape['kind-less-version']).toBe(4)
103
- })
104
-
105
- it('refuses WHOLE above the documented row bound — nothing stamped', async () => {
106
- const rows = Array.from({ length: VERSION_KIND_STAMP_BOUND.rows + 1 }, (_, index) => ({
107
- docId: 'c',
108
- version: index + 1,
109
- label: 'published',
110
- }))
111
- const t = await plant(rows)
112
- await expect(t.mutation(stampVersionKinds, {})).rejects.toThrow(
113
- /exceed the one-transaction stamping bound of 4096 — nothing was stamped/,
114
- )
115
- const after = await stored(t)
116
- expect(after.every((row) => row.kind === undefined)).toBe(true)
117
- }, 60_000)
118
- })