@sqldoc/ns-docs 0.0.1

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.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@sqldoc/ns-docs",
4
+ "version": "0.0.1",
5
+ "description": "Documentation namespace for sqldoc -- generates Markdown/HTML docs with Mermaid ER diagrams via Atlas CLI",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ }
12
+ },
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "files": [
16
+ "src",
17
+ "package.json"
18
+ ],
19
+ "peerDependencies": {
20
+ "@sqldoc/core": "0.0.1",
21
+ "@sqldoc/atlas": "0.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.9.3",
25
+ "vitest": "^4.1.0",
26
+ "@sqldoc/atlas": "0.0.1",
27
+ "@sqldoc/core": "0.0.1"
28
+ },
29
+ "scripts": {
30
+ "test": "vitest run"
31
+ }
32
+ }
@@ -0,0 +1,134 @@
1
+ import type { AtlasRealm } from '@sqldoc/atlas'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { realmToDocsSchema } from '../atlas'
4
+
5
+ const sampleRealm: AtlasRealm = {
6
+ schemas: [
7
+ {
8
+ name: 'public',
9
+ tables: [
10
+ {
11
+ name: 'users',
12
+ columns: [
13
+ { name: 'id', type: { raw: 'bigserial', T: 'bigserial', null: false } },
14
+ { name: 'email', type: { raw: 'text', T: 'text', null: false } },
15
+ { name: 'bio', type: { raw: 'text', T: 'text', null: true } },
16
+ ],
17
+ indexes: [
18
+ {
19
+ name: 'users_email_idx',
20
+ unique: true,
21
+ parts: [{ column: 'email' }],
22
+ },
23
+ ],
24
+ primary_key: { parts: [{ column: 'id' }] },
25
+ foreign_keys: [],
26
+ },
27
+ {
28
+ name: 'posts',
29
+ columns: [
30
+ { name: 'id', type: { raw: 'bigserial', T: 'bigserial', null: false } },
31
+ { name: 'user_id', type: { raw: 'bigint', T: 'bigint', null: false } },
32
+ ],
33
+ primary_key: { parts: [{ column: 'id' }] },
34
+ foreign_keys: [
35
+ {
36
+ symbol: 'posts_user_id_fkey',
37
+ columns: ['user_id'],
38
+ ref_table: 'users',
39
+ ref_columns: ['id'],
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ views: [
45
+ {
46
+ name: 'active_users',
47
+ columns: [{ name: 'email', type: { raw: 'text', T: 'text' } }],
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ }
53
+
54
+ describe('realmToDocsSchema', () => {
55
+ it('converts Atlas WASI realm to ns-docs AtlasSchema format', () => {
56
+ const result = realmToDocsSchema(sampleRealm)
57
+ expect(result.schemas).toHaveLength(1)
58
+ expect(result.schemas[0].name).toBe('public')
59
+ })
60
+
61
+ it('maps tables with columns', () => {
62
+ const result = realmToDocsSchema(sampleRealm)
63
+ const tables = result.schemas[0].tables!
64
+ expect(tables).toHaveLength(2)
65
+ expect(tables[0].name).toBe('users')
66
+ expect(tables[0].columns).toHaveLength(3)
67
+ expect(tables[0].columns[0].name).toBe('id')
68
+ expect(tables[0].columns[0].type).toBe('bigserial')
69
+ })
70
+
71
+ it('maps nullable columns', () => {
72
+ const result = realmToDocsSchema(sampleRealm)
73
+ const bio = result.schemas[0].tables![0].columns[2]
74
+ expect(bio.name).toBe('bio')
75
+ expect(bio.null).toBe(true)
76
+ })
77
+
78
+ it('maps primary key', () => {
79
+ const result = realmToDocsSchema(sampleRealm)
80
+ const pk = result.schemas[0].tables![0].primary_key
81
+ expect(pk).toBeDefined()
82
+ expect(pk!.parts[0].column).toBe('id')
83
+ })
84
+
85
+ it('maps indexes', () => {
86
+ const result = realmToDocsSchema(sampleRealm)
87
+ const indexes = result.schemas[0].tables![0].indexes!
88
+ expect(indexes).toHaveLength(1)
89
+ expect(indexes[0].name).toBe('users_email_idx')
90
+ expect(indexes[0].unique).toBe(true)
91
+ expect(indexes[0].parts[0].column).toBe('email')
92
+ })
93
+
94
+ it('maps foreign keys with references', () => {
95
+ const result = realmToDocsSchema(sampleRealm)
96
+ const fks = result.schemas[0].tables![1].foreign_keys!
97
+ expect(fks).toHaveLength(1)
98
+ expect(fks[0].name).toBe('posts_user_id_fkey')
99
+ expect(fks[0].columns).toEqual(['user_id'])
100
+ expect(fks[0].references.table).toBe('users')
101
+ expect(fks[0].references.columns).toEqual(['id'])
102
+ })
103
+
104
+ it('maps views', () => {
105
+ const result = realmToDocsSchema(sampleRealm)
106
+ const views = result.schemas[0].views!
107
+ expect(views).toHaveLength(1)
108
+ expect(views[0].name).toBe('active_users')
109
+ expect(views[0].columns[0].name).toBe('email')
110
+ })
111
+
112
+ it('handles empty realm', () => {
113
+ const result = realmToDocsSchema({ schemas: [] })
114
+ expect(result.schemas).toHaveLength(0)
115
+ })
116
+
117
+ it('falls back to T when raw is undefined', () => {
118
+ const realm: AtlasRealm = {
119
+ schemas: [
120
+ {
121
+ name: 'test',
122
+ tables: [
123
+ {
124
+ name: 't',
125
+ columns: [{ name: 'x', type: { T: 'integer' } }],
126
+ },
127
+ ],
128
+ },
129
+ ],
130
+ }
131
+ const result = realmToDocsSchema(realm)
132
+ expect(result.schemas[0].tables![0].columns[0].type).toBe('integer')
133
+ })
134
+ })
@@ -0,0 +1,401 @@
1
+ import type { CompilerOutput } from '@sqldoc/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { mergeSchemaWithTags } from '../merge'
4
+ import type { AtlasSchema } from '../types'
5
+
6
+ // ── Fixtures ─────────────────────────────────────────────────────────
7
+
8
+ function makeAtlasSchema(overrides: Partial<AtlasSchema> = {}): AtlasSchema {
9
+ return {
10
+ schemas: [
11
+ {
12
+ name: 'public',
13
+ tables: [
14
+ {
15
+ name: 'users',
16
+ columns: [
17
+ { name: 'id', type: 'bigserial' },
18
+ { name: 'email', type: 'text', null: true },
19
+ { name: 'name', type: 'text' },
20
+ ],
21
+ primary_key: { parts: [{ column: 'id' }] },
22
+ indexes: [{ name: 'users_email_idx', unique: true, parts: [{ column: 'email' }] }],
23
+ foreign_keys: [],
24
+ },
25
+ ],
26
+ ...overrides.schemas?.[0],
27
+ },
28
+ ],
29
+ ...overrides,
30
+ }
31
+ }
32
+
33
+ function makeFileTags(
34
+ objects: Array<{
35
+ objectName: string
36
+ target: 'table' | 'column' | 'view' | 'function' | 'type' | 'index' | 'trigger'
37
+ tags: Array<{ namespace: string; tag: string | null; args: Record<string, unknown> | unknown[] }>
38
+ }> = [],
39
+ ) {
40
+ return [
41
+ {
42
+ sourceFile: 'schema.sql',
43
+ objects,
44
+ },
45
+ ]
46
+ }
47
+
48
+ function makeOutput(overrides: Partial<CompilerOutput> = {}): CompilerOutput {
49
+ return {
50
+ sourceFile: 'schema.sql',
51
+ mergedSql: '',
52
+ sqlOutputs: [],
53
+ codeOutputs: [],
54
+ errors: [],
55
+ docsMeta: [],
56
+ fileTags: [],
57
+ ...overrides,
58
+ }
59
+ }
60
+
61
+ // ── Tests ────────────────────────────────────────────────────────────
62
+
63
+ describe('mergeSchemaWithTags', () => {
64
+ it('merges Atlas table with matching sqldoc tags by normalized name', () => {
65
+ const schema = makeAtlasSchema()
66
+ const fileTags = makeFileTags([
67
+ {
68
+ objectName: 'users',
69
+ target: 'table',
70
+ tags: [{ namespace: 'docs', tag: 'description', args: ['User accounts table'] }],
71
+ },
72
+ ])
73
+
74
+ const result = mergeSchemaWithTags(schema, 'erDiagram', fileTags, [], 'Test')
75
+
76
+ expect(result.tables).toHaveLength(1)
77
+ expect(result.tables[0].name).toBe('users')
78
+ expect(result.tables[0].description).toBe('User accounts table')
79
+ })
80
+
81
+ it('docs.emit(false) excludes table from output', () => {
82
+ const schema = makeAtlasSchema()
83
+ const fileTags = makeFileTags([
84
+ {
85
+ objectName: 'users',
86
+ target: 'table',
87
+ tags: [{ namespace: 'docs', tag: 'emit', args: [false] }],
88
+ },
89
+ ])
90
+
91
+ const result = mergeSchemaWithTags(schema, 'erDiagram', fileTags, [], 'Test')
92
+
93
+ expect(result.tables).toHaveLength(0)
94
+ })
95
+
96
+ it('docs.emit(true) or no emit tag includes table (default include)', () => {
97
+ const schema = makeAtlasSchema()
98
+ // No tags at all -- default include
99
+ const result1 = mergeSchemaWithTags(schema, 'erDiagram', [], [], 'Test')
100
+ expect(result1.tables).toHaveLength(1)
101
+
102
+ // Explicit emit(true)
103
+ const fileTags = makeFileTags([
104
+ {
105
+ objectName: 'users',
106
+ target: 'table',
107
+ tags: [{ namespace: 'docs', tag: 'emit', args: [true] }],
108
+ },
109
+ ])
110
+ const result2 = mergeSchemaWithTags(schema, 'erDiagram', fileTags, [], 'Test')
111
+ expect(result2.tables).toHaveLength(1)
112
+ })
113
+
114
+ it('docs.description on table sets MergedTable.description', () => {
115
+ const schema = makeAtlasSchema()
116
+ const fileTags = makeFileTags([
117
+ {
118
+ objectName: 'users',
119
+ target: 'table',
120
+ tags: [{ namespace: 'docs', tag: 'description', args: ['Core user accounts'] }],
121
+ },
122
+ ])
123
+
124
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
125
+
126
+ expect(result.tables[0].description).toBe('Core user accounts')
127
+ })
128
+
129
+ it('docs.description on column sets MergedColumn.description', () => {
130
+ const schema = makeAtlasSchema()
131
+ const fileTags = makeFileTags([
132
+ {
133
+ objectName: 'email',
134
+ target: 'column',
135
+ tags: [{ namespace: 'docs', tag: 'description', args: ['Primary email address'] }],
136
+ },
137
+ ])
138
+
139
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
140
+
141
+ const emailCol = result.tables[0].columns.find((c) => c.name === 'email')
142
+ expect(emailCol?.description).toBe('Primary email address')
143
+ })
144
+
145
+ it('tags from ALL namespaces (not just docs) are included on MergedTable.tags', () => {
146
+ const schema = makeAtlasSchema()
147
+ const fileTags = makeFileTags([
148
+ {
149
+ objectName: 'users',
150
+ target: 'table',
151
+ tags: [
152
+ { namespace: 'docs', tag: 'description', args: ['User accounts'] },
153
+ { namespace: 'audit', tag: 'track', args: { operations: ['INSERT', 'UPDATE'] } },
154
+ { namespace: 'rls', tag: 'policy', args: ['admin_only'] },
155
+ ],
156
+ },
157
+ ])
158
+
159
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
160
+
161
+ expect(result.tables[0].tags).toHaveLength(3)
162
+ expect(result.tables[0].tags.map((t) => t.namespace)).toEqual(['docs', 'audit', 'rls'])
163
+ })
164
+
165
+ it('PK columns flagged isPrimaryKey: true', () => {
166
+ const schema = makeAtlasSchema()
167
+
168
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
169
+
170
+ const idCol = result.tables[0].columns.find((c) => c.name === 'id')
171
+ expect(idCol?.isPrimaryKey).toBe(true)
172
+
173
+ const emailCol = result.tables[0].columns.find((c) => c.name === 'email')
174
+ expect(emailCol?.isPrimaryKey).toBe(false)
175
+ })
176
+
177
+ it('FK columns flagged isForeignKey: true', () => {
178
+ const schema: AtlasSchema = {
179
+ schemas: [
180
+ {
181
+ name: 'public',
182
+ tables: [
183
+ {
184
+ name: 'posts',
185
+ columns: [
186
+ { name: 'id', type: 'bigserial' },
187
+ { name: 'user_id', type: 'bigint' },
188
+ { name: 'title', type: 'text' },
189
+ ],
190
+ primary_key: { parts: [{ column: 'id' }] },
191
+ foreign_keys: [
192
+ {
193
+ name: 'posts_user_id_fkey',
194
+ columns: ['user_id'],
195
+ references: { table: 'users', columns: ['id'] },
196
+ },
197
+ ],
198
+ },
199
+ ],
200
+ },
201
+ ],
202
+ }
203
+
204
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
205
+
206
+ const userIdCol = result.tables[0].columns.find((c) => c.name === 'user_id')
207
+ expect(userIdCol?.isForeignKey).toBe(true)
208
+
209
+ const titleCol = result.tables[0].columns.find((c) => c.name === 'title')
210
+ expect(titleCol?.isForeignKey).toBe(false)
211
+ })
212
+
213
+ it('column nullable reflects Atlas null field', () => {
214
+ const schema = makeAtlasSchema()
215
+
216
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
217
+
218
+ const emailCol = result.tables[0].columns.find((c) => c.name === 'email')
219
+ expect(emailCol?.nullable).toBe(true) // email has null: true
220
+
221
+ const nameCol = result.tables[0].columns.find((c) => c.name === 'name')
222
+ expect(nameCol?.nullable).toBe(false) // name has no null field
223
+ })
224
+
225
+ it('generated tables (from sqlOutputs) marked isGenerated: true with generatedBy', () => {
226
+ const schema: AtlasSchema = {
227
+ schemas: [
228
+ {
229
+ name: 'public',
230
+ tables: [
231
+ {
232
+ name: 'users',
233
+ columns: [{ name: 'id', type: 'bigserial' }],
234
+ },
235
+ {
236
+ name: 'users_audit',
237
+ columns: [
238
+ { name: 'id', type: 'bigserial' },
239
+ { name: 'action', type: 'text' },
240
+ ],
241
+ },
242
+ ],
243
+ },
244
+ ],
245
+ }
246
+
247
+ const outputs: CompilerOutput[] = [
248
+ makeOutput({
249
+ sqlOutputs: [
250
+ {
251
+ sql: 'CREATE TABLE "users_audit" (id bigserial, action text);',
252
+ sourceTag: '@audit.track',
253
+ },
254
+ ],
255
+ }),
256
+ ]
257
+
258
+ const result = mergeSchemaWithTags(schema, '', [], outputs, 'Test')
259
+
260
+ const usersTable = result.tables.find((t) => t.name === 'users')!
261
+ expect(usersTable.isGenerated).toBe(false)
262
+ expect(usersTable.generatedBy).toBeUndefined()
263
+
264
+ const auditTable = result.tables.find((t) => t.name === 'users_audit')!
265
+ expect(auditTable.isGenerated).toBe(true)
266
+ expect(auditTable.generatedBy).toBe('audit')
267
+ })
268
+
269
+ it('views from Atlas are included in merged output', () => {
270
+ const schema: AtlasSchema = {
271
+ schemas: [
272
+ {
273
+ name: 'public',
274
+ tables: [],
275
+ views: [
276
+ {
277
+ name: 'active_users',
278
+ columns: [
279
+ { name: 'id', type: 'bigserial' },
280
+ { name: 'email', type: 'text', null: true },
281
+ ],
282
+ },
283
+ ],
284
+ },
285
+ ],
286
+ }
287
+
288
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
289
+
290
+ expect(result.views).toHaveLength(1)
291
+ expect(result.views[0].name).toBe('active_users')
292
+ expect(result.views[0].columns).toHaveLength(2)
293
+ expect(result.views[0].columns[0].nullable).toBe(false) // no null field
294
+ expect(result.views[0].columns[1].nullable).toBe(true) // null: true
295
+ })
296
+
297
+ it('case-insensitive name matching between Atlas and sqldoc tags', () => {
298
+ const schema = makeAtlasSchema()
299
+ // Use quoted uppercase name in tags
300
+ const fileTags = makeFileTags([
301
+ {
302
+ objectName: '"Users"',
303
+ target: 'table',
304
+ tags: [{ namespace: 'docs', tag: 'description', args: ['Matched despite case'] }],
305
+ },
306
+ ])
307
+
308
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
309
+
310
+ expect(result.tables[0].description).toBe('Matched despite case')
311
+ })
312
+
313
+ it('mermaidERD is passed through to MergedSchema', () => {
314
+ const schema = makeAtlasSchema()
315
+ const mermaid = 'erDiagram\n users {\n bigserial id PK\n }'
316
+
317
+ const result = mergeSchemaWithTags(schema, mermaid, [], [], 'Test')
318
+
319
+ expect(result.mermaidERD).toBe(mermaid)
320
+ })
321
+
322
+ it('generatedAt is a valid ISO date string', () => {
323
+ const schema = makeAtlasSchema()
324
+
325
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
326
+
327
+ expect(result.generatedAt).toBeDefined()
328
+ const date = new Date(result.generatedAt)
329
+ expect(date.getTime()).not.toBeNaN()
330
+ expect(result.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
331
+ })
332
+
333
+ it('docs.emit(false) on view excludes it from output', () => {
334
+ const schema: AtlasSchema = {
335
+ schemas: [
336
+ {
337
+ name: 'public',
338
+ tables: [],
339
+ views: [
340
+ {
341
+ name: 'hidden_view',
342
+ columns: [{ name: 'id', type: 'int' }],
343
+ },
344
+ ],
345
+ },
346
+ ],
347
+ }
348
+ const fileTags = makeFileTags([
349
+ {
350
+ objectName: 'hidden_view',
351
+ target: 'view',
352
+ tags: [{ namespace: 'docs', tag: 'emit', args: [false] }],
353
+ },
354
+ ])
355
+
356
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
357
+
358
+ expect(result.views).toHaveLength(0)
359
+ })
360
+
361
+ it('docs.previously on table sets MergedTable.previously', () => {
362
+ const schema = makeAtlasSchema()
363
+ const fileTags = makeFileTags([
364
+ {
365
+ objectName: 'users',
366
+ target: 'table',
367
+ tags: [{ namespace: 'docs', tag: 'previously', args: ['old_users'] }],
368
+ },
369
+ ])
370
+
371
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
372
+
373
+ expect(result.tables[0].previously).toBe('old_users')
374
+ })
375
+
376
+ it('docs.previously on column sets MergedColumn.previously', () => {
377
+ const schema = makeAtlasSchema()
378
+ const fileTags = makeFileTags([
379
+ {
380
+ objectName: 'email',
381
+ target: 'column',
382
+ tags: [{ namespace: 'docs', tag: 'previously', args: ['email_address'] }],
383
+ },
384
+ ])
385
+
386
+ const result = mergeSchemaWithTags(schema, '', fileTags, [], 'Test')
387
+
388
+ const emailCol = result.tables[0].columns.find((c) => c.name === 'email')
389
+ expect(emailCol?.previously).toBe('email_address')
390
+ })
391
+
392
+ it('previously is undefined when no @docs.previously tag', () => {
393
+ const schema = makeAtlasSchema()
394
+ const result = mergeSchemaWithTags(schema, '', [], [], 'Test')
395
+
396
+ expect(result.tables[0].previously).toBeUndefined()
397
+ for (const col of result.tables[0].columns) {
398
+ expect(col.previously).toBeUndefined()
399
+ }
400
+ })
401
+ })
@@ -0,0 +1,107 @@
1
+ import type { AtlasRealm } from '@sqldoc/atlas'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { generateMermaidERD } from '../mermaid'
4
+
5
+ // Minimal mock AtlasRealm matching the actual lowercase JSON from marshal.go
6
+ const realm: AtlasRealm = {
7
+ schemas: [
8
+ {
9
+ name: 'public',
10
+ tables: [
11
+ {
12
+ name: 'users',
13
+ columns: [
14
+ { name: 'id', type: { raw: 'bigint', T: 'bigint', null: false } },
15
+ { name: 'email', type: { raw: 'text', T: 'text', null: false } },
16
+ ],
17
+ primary_key: { parts: [{ column: 'id' }] },
18
+ foreign_keys: [],
19
+ },
20
+ {
21
+ name: 'posts',
22
+ columns: [
23
+ { name: 'id', type: { raw: 'bigint', T: 'bigint', null: false } },
24
+ { name: 'user_id', type: { raw: 'bigint', T: 'bigint', null: false } },
25
+ { name: 'title', type: { raw: 'text', T: 'text', null: true } },
26
+ ],
27
+ primary_key: { parts: [{ column: 'id' }] },
28
+ foreign_keys: [
29
+ {
30
+ symbol: 'posts_user_id_fkey',
31
+ columns: ['user_id'],
32
+ ref_table: 'users',
33
+ ref_columns: ['id'],
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ views: [
39
+ {
40
+ name: 'active_users',
41
+ columns: [{ name: 'email', type: { raw: 'text', T: 'text' } }],
42
+ },
43
+ ],
44
+ },
45
+ ],
46
+ }
47
+
48
+ describe('generateMermaidERD', () => {
49
+ it('generates valid erDiagram header', () => {
50
+ const result = generateMermaidERD(realm)
51
+ expect(result).toMatch(/^erDiagram/)
52
+ })
53
+
54
+ it('table with columns produces entity block with types', () => {
55
+ const result = generateMermaidERD(realm)
56
+ expect(result).toContain('users {')
57
+ expect(result).toContain('bigint id')
58
+ expect(result).toContain('text email')
59
+ })
60
+
61
+ it('PK column marked with PK constraint', () => {
62
+ const result = generateMermaidERD(realm)
63
+ // users.id is PK
64
+ expect(result).toMatch(/bigint id PK/)
65
+ })
66
+
67
+ it('FK column marked with FK constraint and produces relationship line', () => {
68
+ const result = generateMermaidERD(realm)
69
+ // posts.user_id is FK
70
+ expect(result).toMatch(/bigint user_id FK/)
71
+ // Relationship line
72
+ expect(result).toMatch(/posts\s+\}o--\|\|\s+users/)
73
+ })
74
+
75
+ it('view produces entity block', () => {
76
+ const result = generateMermaidERD(realm)
77
+ expect(result).toContain('active_users {')
78
+ expect(result).toContain('text email')
79
+ })
80
+
81
+ it('empty realm produces just "erDiagram"', () => {
82
+ const emptyRealm: AtlasRealm = { schemas: [] }
83
+ const result = generateMermaidERD(emptyRealm)
84
+ expect(result).toBe('erDiagram')
85
+ })
86
+
87
+ it('posts.id is both PK (not FK)', () => {
88
+ const result = generateMermaidERD(realm)
89
+ // posts.id should be PK only, not FK
90
+ const lines = result.split('\n')
91
+ const _postIdLine = lines.find((l) => l.includes('posts') || l.trim().startsWith('bigint id'))
92
+ // Within the posts entity, id should have PK
93
+ // Grab lines between "posts {" and the closing "}"
94
+ const postsStart = lines.findIndex((l) => l.includes('posts {'))
95
+ const postsEnd = lines.findIndex((l, i) => i > postsStart && l.trim() === '}')
96
+ const postsLines = lines.slice(postsStart + 1, postsEnd)
97
+ const idLine = postsLines.find((l) => l.trim().startsWith('bigint id'))
98
+ expect(idLine).toBeDefined()
99
+ expect(idLine).toContain('PK')
100
+ expect(idLine).not.toContain('FK')
101
+ })
102
+
103
+ it('FK relationship includes foreign key symbol as label', () => {
104
+ const result = generateMermaidERD(realm)
105
+ expect(result).toContain('posts_user_id_fkey')
106
+ })
107
+ })