Skip to content

Commit 634faae

Browse files
committed
Revert "refactor(hocuspocus): remove nested-to-flat schema migration"
This reverts commit 39f7697.
1 parent 39f7697 commit 634faae

6 files changed

Lines changed: 729 additions & 0 deletions

File tree

packages/hocuspocus.server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"prisma:migrate:dev": "bun --env-file ../../.env.local prisma migrate dev",
2222
"prisma:generate": "bun --env-file ../../.env.local prisma generate --schema=./prisma/schema.prisma",
2323
"fix:migration": "bun scripts/fix-migration.ts",
24+
"migrate:nested-to-flat": "bun --env-file ../../.env.local src/scripts/migrate-nested-to-flat.ts",
25+
"migrate:nested-to-flat:dry": "bun --env-file ../../.env.local src/scripts/migrate-nested-to-flat.ts --dry-run",
2426
"update:packages": "bunx npm-check-updates -u"
2527
},
2628
"keywords": [],

packages/hocuspocus.server/src/config/hocuspocus.config.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TiptapTransformer } from '@hocuspocus/transformer'
12
import * as Y from 'yjs'
23
import { Database } from '@hocuspocus/extension-database'
34
import { Throttle } from '@hocuspocus/extension-throttle'
@@ -10,6 +11,8 @@ import { RedisSubscriberExtension } from '../extensions/redis-subscriber.extensi
1011
import { DocumentViewsExtension } from '../extensions/document-views.extension'
1112
import { prisma } from '../lib/prisma'
1213
import { dbLogger } from '../lib/logger'
14+
import { isOldSchema, transformNestedToFlat } from '../lib/schema-migration'
15+
import { migrationExtensions } from '../lib/migration-extensions'
1316

1417
export { Database }
1518

@@ -81,6 +84,36 @@ const configureExtensions = () => {
8184

8285
const rawState = doc?.data ?? generateDefaultState()
8386

87+
// On-load migration: convert old nested schema to flat on first open.
88+
// Controlled by ENABLE_SCHEMA_MIGRATION env var — remove after all docs migrated.
89+
if (doc?.data && checkEnvBolean(process.env.ENABLE_SCHEMA_MIGRATION)) {
90+
try {
91+
const ydoc = new Y.Doc()
92+
const buffer = rawState instanceof Buffer ? new Uint8Array(rawState) : rawState
93+
Y.applyUpdate(ydoc, buffer)
94+
const pmJson = TiptapTransformer.fromYdoc(ydoc, 'default') as Record<
95+
string,
96+
unknown
97+
> | null
98+
99+
if (pmJson && isOldSchema(pmJson as any)) {
100+
dbLogger.info({ documentName }, 'On-load schema migration: nested → flat')
101+
const flatJson = transformNestedToFlat(pmJson as any)
102+
const newYdoc = TiptapTransformer.toYdoc(
103+
flatJson as unknown as Record<string, unknown>,
104+
'default',
105+
migrationExtensions
106+
)
107+
return Y.encodeStateAsUpdate(newYdoc)
108+
}
109+
} catch (migrationErr) {
110+
dbLogger.warn(
111+
{ err: migrationErr, documentName },
112+
'Schema migration failed, serving original'
113+
)
114+
}
115+
}
116+
84117
return rawState
85118
} catch (err) {
86119
dbLogger.error({ err }, 'Error fetching document data')
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { isOldSchema, transformNestedToFlat } from '../schema-migration'
4+
5+
describe('isOldSchema', () => {
6+
it('returns true for docs with contentHeading nodes', () => {
7+
const doc = {
8+
type: 'doc',
9+
content: [
10+
{
11+
type: 'heading',
12+
attrs: { level: 1, id: 'h1' },
13+
content: [
14+
{ type: 'contentHeading', content: [{ type: 'text', text: 'Title' }] },
15+
{ type: 'contentWrapper', content: [] }
16+
]
17+
}
18+
]
19+
}
20+
expect(isOldSchema(doc)).toBe(true)
21+
})
22+
23+
it('returns false for flat schema docs', () => {
24+
const doc = {
25+
type: 'doc',
26+
content: [
27+
{
28+
type: 'heading',
29+
attrs: { level: 1, 'toc-id': 'abc' },
30+
content: [{ type: 'text', text: 'Title' }]
31+
},
32+
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }
33+
]
34+
}
35+
expect(isOldSchema(doc)).toBe(false)
36+
})
37+
38+
it('returns false for empty doc', () => {
39+
expect(isOldSchema({ type: 'doc' })).toBe(false)
40+
expect(isOldSchema({ type: 'doc', content: [] })).toBe(false)
41+
})
42+
})
43+
44+
describe('transformNestedToFlat', () => {
45+
it('flattens a simple nested heading', () => {
46+
const doc = {
47+
type: 'doc',
48+
content: [
49+
{
50+
type: 'heading',
51+
attrs: { level: 1, id: 'title-id' },
52+
content: [
53+
{
54+
type: 'contentHeading',
55+
attrs: { level: 1 },
56+
content: [{ type: 'text', text: 'My Title' }]
57+
},
58+
{
59+
type: 'contentWrapper',
60+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body text' }] }]
61+
}
62+
]
63+
}
64+
]
65+
}
66+
67+
const result = transformNestedToFlat(doc)
68+
69+
expect(result.content).toHaveLength(2)
70+
expect(result.content![0].type).toBe('heading')
71+
expect(result.content![0].attrs!.level).toBe(1)
72+
expect(result.content![0].attrs!['toc-id']).toBe('title-id')
73+
expect(result.content![0].content![0].text).toBe('My Title')
74+
expect(result.content![1].type).toBe('paragraph')
75+
expect(result.content![1].content![0].text).toBe('Body text')
76+
})
77+
78+
it('flattens deeply nested headings (3 levels)', () => {
79+
const doc = {
80+
type: 'doc',
81+
content: [
82+
{
83+
type: 'heading',
84+
attrs: { level: 1, id: 'h1' },
85+
content: [
86+
{
87+
type: 'contentHeading',
88+
attrs: { level: 1 },
89+
content: [{ type: 'text', text: 'H1' }]
90+
},
91+
{
92+
type: 'contentWrapper',
93+
content: [
94+
{ type: 'paragraph', content: [{ type: 'text', text: 'H1 body' }] },
95+
{
96+
type: 'heading',
97+
attrs: { level: 2, id: 'h2' },
98+
content: [
99+
{
100+
type: 'contentHeading',
101+
attrs: { level: 2 },
102+
content: [{ type: 'text', text: 'H2' }]
103+
},
104+
{
105+
type: 'contentWrapper',
106+
content: [
107+
{ type: 'paragraph', content: [{ type: 'text', text: 'H2 body' }] },
108+
{
109+
type: 'heading',
110+
attrs: { level: 3, id: 'h3' },
111+
content: [
112+
{
113+
type: 'contentHeading',
114+
attrs: { level: 3 },
115+
content: [{ type: 'text', text: 'H3' }]
116+
},
117+
{
118+
type: 'contentWrapper',
119+
content: [
120+
{ type: 'paragraph', content: [{ type: 'text', text: 'H3 body' }] }
121+
]
122+
}
123+
]
124+
}
125+
]
126+
}
127+
]
128+
}
129+
]
130+
}
131+
]
132+
}
133+
]
134+
}
135+
136+
const result = transformNestedToFlat(doc)
137+
138+
expect(result.content).toHaveLength(6)
139+
expect(result.content![0]).toMatchObject({
140+
type: 'heading',
141+
attrs: { level: 1, 'toc-id': 'h1' }
142+
})
143+
expect(result.content![0].content![0].text).toBe('H1')
144+
expect(result.content![1]).toMatchObject({ type: 'paragraph' })
145+
expect(result.content![2]).toMatchObject({
146+
type: 'heading',
147+
attrs: { level: 2, 'toc-id': 'h2' }
148+
})
149+
expect(result.content![3]).toMatchObject({ type: 'paragraph' })
150+
expect(result.content![4]).toMatchObject({
151+
type: 'heading',
152+
attrs: { level: 3, 'toc-id': 'h3' }
153+
})
154+
expect(result.content![5]).toMatchObject({ type: 'paragraph' })
155+
})
156+
157+
it('preserves mixed content (lists, code blocks) inside contentWrapper', () => {
158+
const doc = {
159+
type: 'doc',
160+
content: [
161+
{
162+
type: 'heading',
163+
attrs: { level: 1, id: 'h1' },
164+
content: [
165+
{
166+
type: 'contentHeading',
167+
attrs: { level: 1 },
168+
content: [{ type: 'text', text: 'Title' }]
169+
},
170+
{
171+
type: 'contentWrapper',
172+
content: [
173+
{ type: 'paragraph', content: [{ type: 'text', text: 'Intro' }] },
174+
{
175+
type: 'bulletList',
176+
content: [
177+
{
178+
type: 'listItem',
179+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }]
180+
}
181+
]
182+
},
183+
{
184+
type: 'codeBlock',
185+
attrs: { language: 'js' },
186+
content: [{ type: 'text', text: 'const x = 1' }]
187+
}
188+
]
189+
}
190+
]
191+
}
192+
]
193+
}
194+
195+
const result = transformNestedToFlat(doc)
196+
197+
expect(result.content).toHaveLength(4)
198+
expect(result.content![0].type).toBe('heading')
199+
expect(result.content![1].type).toBe('paragraph')
200+
expect(result.content![2].type).toBe('bulletList')
201+
expect(result.content![3].type).toBe('codeBlock')
202+
expect(result.content![3].attrs!.language).toBe('js')
203+
})
204+
205+
it('handles empty contentWrapper', () => {
206+
const doc = {
207+
type: 'doc',
208+
content: [
209+
{
210+
type: 'heading',
211+
attrs: { level: 1, id: 'h1' },
212+
content: [
213+
{
214+
type: 'contentHeading',
215+
attrs: { level: 1 },
216+
content: [{ type: 'text', text: 'Title' }]
217+
},
218+
{ type: 'contentWrapper', content: [] }
219+
]
220+
}
221+
]
222+
}
223+
224+
const result = transformNestedToFlat(doc)
225+
226+
expect(result.content).toHaveLength(1)
227+
expect(result.content![0].type).toBe('heading')
228+
expect(result.content![0].content![0].text).toBe('Title')
229+
})
230+
231+
it('is idempotent — flat docs pass through unchanged', () => {
232+
const doc = {
233+
type: 'doc',
234+
content: [
235+
{
236+
type: 'heading',
237+
attrs: { level: 1, 'toc-id': 'abc' },
238+
content: [{ type: 'text', text: 'Title' }]
239+
},
240+
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }
241+
]
242+
}
243+
244+
const result = transformNestedToFlat(doc)
245+
expect(result).toEqual(doc)
246+
})
247+
248+
it('normalizes data-toc-id attr to toc-id', () => {
249+
const doc = {
250+
type: 'doc',
251+
content: [
252+
{
253+
type: 'heading',
254+
attrs: { level: 1, 'data-toc-id': 'old-id' },
255+
content: [
256+
{
257+
type: 'contentHeading',
258+
attrs: { level: 1 },
259+
content: [{ type: 'text', text: 'Title' }]
260+
},
261+
{ type: 'contentWrapper', content: [] }
262+
]
263+
}
264+
]
265+
}
266+
267+
const result = transformNestedToFlat(doc)
268+
expect(result.content![0].attrs!['toc-id']).toBe('old-id')
269+
expect(result.content![0].attrs!['data-toc-id']).toBeUndefined()
270+
})
271+
272+
it('clamps heading levels 7-10 to 6', () => {
273+
const doc = {
274+
type: 'doc',
275+
content: [
276+
{
277+
type: 'heading',
278+
attrs: { level: 8, id: 'h8' },
279+
content: [
280+
{
281+
type: 'contentHeading',
282+
attrs: { level: 8 },
283+
content: [{ type: 'text', text: 'Deep' }]
284+
},
285+
{ type: 'contentWrapper', content: [] }
286+
]
287+
}
288+
]
289+
}
290+
291+
const result = transformNestedToFlat(doc)
292+
expect(result.content![0].attrs!.level).toBe(6)
293+
})
294+
})

0 commit comments

Comments
 (0)