@sqldoc/ns-docs 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sqldoc/ns-docs",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "description": "Documentation namespace for sqldoc -- generates Markdown/HTML docs with Mermaid ER diagrams via Atlas CLI",
6
6
  "exports": {
7
7
  ".": {
@@ -14,17 +14,18 @@
14
14
  "types": "./src/index.ts",
15
15
  "files": [
16
16
  "src",
17
+ "!src/__tests__",
17
18
  "package.json"
18
19
  ],
19
20
  "peerDependencies": {
20
- "@sqldoc/core": "0.0.1",
21
- "@sqldoc/atlas": "0.0.1"
21
+ "@sqldoc/core": "0.0.2",
22
+ "@sqldoc/atlas": "0.0.2"
22
23
  },
23
24
  "devDependencies": {
24
25
  "typescript": "^5.9.3",
25
26
  "vitest": "^4.1.0",
26
- "@sqldoc/atlas": "0.0.1",
27
- "@sqldoc/core": "0.0.1"
27
+ "@sqldoc/core": "0.0.2",
28
+ "@sqldoc/atlas": "0.0.2"
28
29
  },
29
30
  "scripts": {
30
31
  "test": "vitest run"
@@ -1,134 +0,0 @@
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
- })
@@ -1,401 +0,0 @@
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
- })
@@ -1,107 +0,0 @@
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
- })
@@ -1,109 +0,0 @@
1
- import type { MergedSchema } from '../../types'
2
-
3
- /**
4
- * Shared test fixture: 2 tables (one generated, one not), 1 view,
5
- * a mermaid ERD string, and various tags.
6
- */
7
- export function makeTestSchema(): MergedSchema {
8
- return {
9
- title: 'Test Schema Docs',
10
- generatedAt: '2026-03-19T00:00:00.000Z',
11
- mermaidERD: `erDiagram
12
- users {
13
- bigserial id PK
14
- text email
15
- text name
16
- }
17
- posts {
18
- bigserial id PK
19
- bigint user_id FK
20
- text title
21
- }
22
- posts }o--o| users : posts_user_id_fkey`,
23
- tables: [
24
- {
25
- name: 'users',
26
- description: 'User accounts table',
27
- previously: 'old_users',
28
- isGenerated: false,
29
- columns: [
30
- { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: true, isForeignKey: false, tags: [] },
31
- {
32
- name: 'email',
33
- type: 'text',
34
- nullable: true,
35
- description: 'Primary email',
36
- previously: 'email_address',
37
- isPrimaryKey: false,
38
- isForeignKey: false,
39
- tags: [],
40
- },
41
- { name: 'name', type: 'text', nullable: false, isPrimaryKey: false, isForeignKey: false, tags: [] },
42
- ],
43
- indexes: [{ name: 'users_email_idx', unique: true, parts: [{ column: 'email' }] }],
44
- primaryKey: { parts: [{ column: 'id' }] },
45
- foreignKeys: [],
46
- tags: [
47
- { namespace: 'docs', tag: 'description', args: ['User accounts table'] },
48
- { namespace: 'audit', tag: 'track', args: { operations: ['INSERT', 'UPDATE'] } },
49
- { namespace: 'rls', tag: null, args: ['admin_only'] },
50
- ],
51
- },
52
- {
53
- name: 'posts',
54
- isGenerated: true,
55
- generatedBy: 'audit',
56
- columns: [
57
- { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: true, isForeignKey: false, tags: [] },
58
- { name: 'user_id', type: 'bigint', nullable: false, isPrimaryKey: false, isForeignKey: true, tags: [] },
59
- { name: 'title', type: 'text', nullable: true, isPrimaryKey: false, isForeignKey: false, tags: [] },
60
- ],
61
- indexes: [],
62
- primaryKey: { parts: [{ column: 'id' }] },
63
- foreignKeys: [
64
- {
65
- name: 'posts_user_id_fkey',
66
- columns: ['user_id'],
67
- references: { table: 'users', columns: ['id'] },
68
- },
69
- ],
70
- tags: [],
71
- },
72
- ],
73
- views: [
74
- {
75
- name: 'active_users',
76
- description: 'Active users view',
77
- columns: [
78
- { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: false, isForeignKey: false, tags: [] },
79
- { name: 'email', type: 'text', nullable: true, isPrimaryKey: false, isForeignKey: false, tags: [] },
80
- ],
81
- tags: [{ namespace: 'docs', tag: 'description', args: ['Active users view'] }],
82
- },
83
- ],
84
- extraRelationships: [],
85
- annotations: [
86
- { object: 'users', text: 'Audited (insert, update)' },
87
- { object: 'users', text: 'RLS enabled' },
88
- ],
89
- extraColumnHeaders: ['Anonymization'],
90
- extraColumnData: new Map([['users:email:Anonymization', 'Masked: anon.fake_email()']]),
91
- }
92
- }
93
-
94
- /**
95
- * Minimal schema with no tables, no views -- for edge case testing.
96
- */
97
- export function makeMinimalSchema(): MergedSchema {
98
- return {
99
- title: 'Empty Schema',
100
- generatedAt: '2026-03-19T00:00:00.000Z',
101
- mermaidERD: 'erDiagram',
102
- tables: [],
103
- views: [],
104
- extraRelationships: [],
105
- annotations: [],
106
- extraColumnHeaders: [],
107
- extraColumnData: new Map(),
108
- }
109
- }
@@ -1,122 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { renderHtml } from '../../renderers/html'
3
- import { makeMinimalSchema, makeTestSchema } from './fixture'
4
-
5
- describe('renderHtml', () => {
6
- const schema = makeTestSchema()
7
-
8
- it('output starts with <!DOCTYPE html>', () => {
9
- const result = renderHtml(schema)
10
- expect(result.trimStart()).toMatch(/^<!DOCTYPE html>/)
11
- })
12
-
13
- it('output contains <title> with schema title', () => {
14
- const result = renderHtml(schema)
15
- expect(result).toContain('<title>Test Schema Docs</title>')
16
- })
17
-
18
- it('output contains sidebar nav with table links', () => {
19
- const result = renderHtml(schema)
20
- expect(result).toContain('class="sidebar"')
21
- expect(result).toContain('<a href="#users">users</a>')
22
- expect(result).toContain('<a href="#posts">posts</a>')
23
- })
24
-
25
- it('output contains sidebar Views section with view links', () => {
26
- const result = renderHtml(schema)
27
- expect(result).toContain('<h2>Views</h2>')
28
- expect(result).toContain('<a href="#active_users">active_users</a>')
29
- })
30
-
31
- it('output contains <pre class="mermaid"> with ERD content', () => {
32
- const result = renderHtml(schema)
33
- expect(result).toContain('<pre class="mermaid">')
34
- expect(result).toContain('erDiagram')
35
- })
36
-
37
- it('output contains <section id="{tableName}"> for each table', () => {
38
- const result = renderHtml(schema)
39
- expect(result).toContain('<section id="users">')
40
- expect(result).toContain('<section id="posts">')
41
- expect(result).toContain('<section id="active_users">')
42
- })
43
-
44
- it('generated tables have generated-badge span', () => {
45
- const result = renderHtml(schema)
46
- expect(result).toContain('class="generated-badge"')
47
- expect(result).toContain('Generated by @audit')
48
- })
49
-
50
- it('columns rendered in HTML table', () => {
51
- const result = renderHtml(schema)
52
- expect(result).toContain('<th>Column</th>')
53
- expect(result).toContain('<th>Type</th>')
54
- expect(result).toContain('<td>bigserial</td>')
55
- })
56
-
57
- it('annotations rendered as <span class="annotation">', () => {
58
- const result = renderHtml(schema)
59
- expect(result).toContain('<span class="annotation">')
60
- expect(result).toContain('Audited (insert, update)')
61
- expect(result).toContain('RLS enabled')
62
- })
63
-
64
- it('HTML-escaped content (no raw < or > in user strings)', () => {
65
- // Create schema with potentially dangerous content
66
- const dangerousSchema = makeTestSchema()
67
- dangerousSchema.tables[0].description = '<script>alert("xss")</script>'
68
- dangerousSchema.title = 'Test <b>bold</b> title'
69
-
70
- const result = renderHtml(dangerousSchema)
71
- expect(result).not.toContain('<script>alert')
72
- expect(result).toContain('&lt;script&gt;')
73
- expect(result).toContain('&lt;b&gt;bold&lt;/b&gt;')
74
- })
75
-
76
- it('script tag contains IntersectionObserver', () => {
77
- const result = renderHtml(schema)
78
- expect(result).toContain('<script type="module">')
79
- expect(result).toContain('IntersectionObserver')
80
- })
81
-
82
- it('embedded CSS contains sidebar and content styles', () => {
83
- const result = renderHtml(schema)
84
- expect(result).toContain('<style>')
85
- expect(result).toContain('.sidebar')
86
- expect(result).toContain('.content')
87
- expect(result).toContain('.annotation')
88
- expect(result).toContain('.generated-badge')
89
- })
90
-
91
- it('no Views sidebar section when no views present', () => {
92
- const minimal = makeMinimalSchema()
93
- const result = renderHtml(minimal)
94
- // Should not have the Views heading in sidebar
95
- // The sidebar should not contain Views h2 since no views
96
- const sidebarMatch = result.match(/<nav class="sidebar">([\s\S]*?)<\/nav>/)
97
- expect(sidebarMatch).toBeTruthy()
98
- expect(sidebarMatch![1]).not.toContain('<h2>Views</h2>')
99
- })
100
-
101
- it('table with previously shows "formerly" paragraph', () => {
102
- const result = renderHtml(schema)
103
- expect(result).toContain('class="previously"')
104
- expect(result).toContain('formerly: old_users')
105
- })
106
-
107
- it('column with previously shows "formerly" in description cell', () => {
108
- const result = renderHtml(schema)
109
- expect(result).toContain('formerly: email_address')
110
- })
111
-
112
- it('description paragraph has class "description"', () => {
113
- const result = renderHtml(schema)
114
- expect(result).toContain('class="description"')
115
- })
116
-
117
- it('timestamp paragraph has class "timestamp"', () => {
118
- const result = renderHtml(schema)
119
- expect(result).toContain('class="timestamp"')
120
- expect(result).toContain('Generated:')
121
- })
122
- })
@@ -1,121 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { renderMarkdown } from '../../renderers/markdown'
3
- import { makeMinimalSchema, makeTestSchema } from './fixture'
4
-
5
- describe('renderMarkdown', () => {
6
- // Reuse the shared fixture for most tests
7
- const schema = makeTestSchema()
8
-
9
- // Generate once for all tests
10
- const _output = renderMarkdown(schema)
11
-
12
- it('output contains title as H1', () => {
13
- const result = renderMarkdown(schema)
14
- expect(result).toContain('# Test Schema Docs')
15
- })
16
-
17
- it('output contains mermaid code block with erDiagram content', () => {
18
- const result = renderMarkdown(schema)
19
- expect(result).toContain('```mermaid')
20
- expect(result).toContain('erDiagram')
21
- expect(result).toContain('bigserial id PK')
22
- expect(result).toContain('```')
23
- })
24
-
25
- it('output contains table of contents with anchor links', () => {
26
- const result = renderMarkdown(schema)
27
- expect(result).toContain('## Contents')
28
- expect(result).toMatch(/\[users\]\(#users\)/)
29
- expect(result).toMatch(/\[posts\]\(#posts\)/)
30
- expect(result).toMatch(/\[active_users\]\(#active_users\)/)
31
- })
32
-
33
- it('each table has columns table with correct headers', () => {
34
- const result = renderMarkdown(schema)
35
- expect(result).toContain('| Column | Type | Nullable | PK | FK | Description |')
36
- })
37
-
38
- it('generated tables have "(Generated by @namespace)" annotation', () => {
39
- const result = renderMarkdown(schema)
40
- expect(result).toContain('(Generated by @audit)')
41
- })
42
-
43
- it('non-generated tables do not have generated annotation', () => {
44
- const result = renderMarkdown(schema)
45
- // "users" section should NOT have "Generated by"
46
- const usersIdx = result.indexOf('### users')
47
- const postsIdx = result.indexOf('### posts')
48
- const between = result.slice(usersIdx, postsIdx)
49
- expect(between).not.toContain('Generated by')
50
- })
51
-
52
- it('table with previously shows "formerly" line', () => {
53
- const result = renderMarkdown(schema)
54
- expect(result).toContain('*formerly: old_users*')
55
- })
56
-
57
- it('column with previously shows "formerly" in description cell', () => {
58
- const result = renderMarkdown(schema)
59
- expect(result).toContain('*formerly: email_address*')
60
- })
61
-
62
- it('annotations rendered as blockquotes', () => {
63
- const result = renderMarkdown(schema)
64
- expect(result).toContain('> Audited (insert, update)')
65
- expect(result).toContain('> RLS enabled')
66
- })
67
-
68
- it('extra column headers appear in column table', () => {
69
- const result = renderMarkdown(schema)
70
- expect(result).toContain('Anonymization')
71
- expect(result).toContain('Masked: anon.fake_email()')
72
- })
73
-
74
- it('FK section rendered when foreignKeys present', () => {
75
- const result = renderMarkdown(schema)
76
- expect(result).toContain('#### Foreign Keys')
77
- expect(result).toContain('posts_user_id_fkey')
78
- expect(result).toContain('users(id)')
79
- })
80
-
81
- it('indexes section rendered when indexes present', () => {
82
- const result = renderMarkdown(schema)
83
- expect(result).toContain('#### Indexes')
84
- expect(result).toContain('users_email_idx')
85
- expect(result).toContain('UNIQUE')
86
- })
87
-
88
- it('views section rendered when views present', () => {
89
- const result = renderMarkdown(schema)
90
- expect(result).toContain('## Views')
91
- expect(result).toContain('### active_users')
92
- })
93
-
94
- it('empty schema produces minimal output (title + empty sections)', () => {
95
- const minimal = makeMinimalSchema()
96
- const result = renderMarkdown(minimal)
97
- expect(result).toContain('# Empty Schema')
98
- expect(result).toContain('```mermaid')
99
- expect(result).not.toContain('## Views')
100
- })
101
-
102
- it('column rows contain correct PK/FK markers', () => {
103
- const result = renderMarkdown(schema)
104
- // The "id" column in users should have PK=Y
105
- // We need to find the users columns table and check the id row
106
- const lines = result.split('\n')
107
- const idLine = lines.find((l) => l.includes('| id |') && l.includes('bigserial'))
108
- expect(idLine).toBeDefined()
109
- expect(idLine).toContain('| Y |')
110
-
111
- // user_id in posts should have FK=Y
112
- const fkLine = lines.find((l) => l.includes('| user_id |'))
113
- expect(fkLine).toBeDefined()
114
- expect(fkLine).toMatch(/\|\s*Y\s*\|/)
115
- })
116
-
117
- it('generated timestamp is present', () => {
118
- const result = renderMarkdown(schema)
119
- expect(result).toContain('*Generated:')
120
- })
121
- })