@svgrid/enterprise 1.2.0 → 2.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.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +92 -83
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { introspectPrisma, introspectPrismaAll } from './introspect-prisma'
3
+
4
+ const SCHEMA = `
5
+ datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
6
+ generator client { provider = "prisma-client-js" }
7
+
8
+ enum Role {
9
+ ADMIN
10
+ USER
11
+ }
12
+
13
+ model User {
14
+ id Int @id @default(autoincrement())
15
+ email String @unique
16
+ name String?
17
+ role Role @default(USER)
18
+ createdAt DateTime @default(now())
19
+ posts Post[]
20
+ }
21
+
22
+ model Post {
23
+ id Int @id @default(autoincrement())
24
+ title String
25
+ views Int @default(0)
26
+ author User @relation(fields: [authorId], references: [id])
27
+ authorId Int
28
+ }
29
+ `
30
+
31
+ describe('introspectPrisma', () => {
32
+ it('maps scalar types and marks the @id as the read-only key', () => {
33
+ const s = introspectPrisma(SCHEMA, 'User')
34
+ const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
35
+ expect(s.name).toBe('User')
36
+ expect(byField.id!.primaryKey).toBe(true)
37
+ expect(byField.id!.readonly).toBe(true)
38
+ expect(byField.email!.type).toBe('text')
39
+ expect(byField.createdAt!.type).toBe('datetime')
40
+ })
41
+
42
+ it('treats a missing `?` as required and `?` as optional', () => {
43
+ const byField = Object.fromEntries(introspectPrisma(SCHEMA, 'User').fields.map((f) => [f.field, f]))
44
+ expect(byField.email!.required).toBe(true)
45
+ expect(byField.name!.required).toBeUndefined()
46
+ })
47
+
48
+ it('reads an enum field with its options', () => {
49
+ const role = introspectPrisma(SCHEMA, 'User').fields.find((f) => f.field === 'role')!
50
+ expect(role.type).toBe('enum')
51
+ expect(role.options?.map((o) => o.value)).toEqual(['ADMIN', 'USER'])
52
+ })
53
+
54
+ it('turns a @relation FK into a relation field and skips the navigation field', () => {
55
+ const s = introspectPrisma(SCHEMA, 'Post')
56
+ const fieldNames = s.fields.map((f) => f.field)
57
+ expect(fieldNames).toContain('authorId')
58
+ expect(fieldNames).not.toContain('author') // navigation field is virtual
59
+ const rel = s.fields.find((f) => f.field === 'authorId')!
60
+ expect(rel.type).toBe('relation')
61
+ expect(rel.relation?.entity).toBe('User')
62
+ })
63
+
64
+ it('defaults to the first model when none is named', () => {
65
+ expect(introspectPrisma(SCHEMA).name).toBe('User')
66
+ })
67
+
68
+ it('throws when there is no model', () => {
69
+ expect(() => introspectPrisma('generator client {}')).toThrow(/no .*model/)
70
+ })
71
+ })
72
+
73
+ describe('composite primary key (join table)', () => {
74
+ const JOIN = `
75
+ model PostTag {
76
+ postId Int
77
+ tagId Int
78
+ post Post @relation(fields: [postId], references: [id])
79
+ tag Tag @relation(fields: [tagId], references: [id])
80
+ @@id([postId, tagId])
81
+ }
82
+ model Post { id Int @id }
83
+ model Tag { id Int @id }
84
+ `
85
+ it('keys off the first @@id member and keeps the other as a relation', () => {
86
+ const s = introspectPrisma(JOIN, 'PostTag')
87
+ expect(s.idField).toBe('postId')
88
+ expect(s.fields.find((f) => f.field === 'tagId')?.type).toBe('relation')
89
+ })
90
+ })
91
+
92
+ describe('introspectPrismaAll', () => {
93
+ it('returns every model, linked', () => {
94
+ const all = introspectPrismaAll(SCHEMA)
95
+ expect(all.map((s) => s.name)).toEqual(['User', 'Post'])
96
+ const rel = all.find((s) => s.name === 'Post')!.fields.find((f) => f.field === 'authorId')!
97
+ expect(rel.relation?.entity).toBe('User')
98
+ })
99
+ })
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Prisma introspection - turn a `schema.prisma` into `EntitySchema`s, the peer
3
+ * of the Drizzle reader in `introspect.ts`. Same contract: a pure, best-effort
4
+ * text parse (no `@prisma/*` dependency, no code execution), funnelled through
5
+ * the shared `buildEntitySchema` so grid + form come out identical to every
6
+ * other introspection path.
7
+ *
8
+ * - `introspectPrisma(source, model?)` -> one model's schema.
9
+ * - `introspectPrismaAll(source)` -> every model, linked (relations
10
+ * resolve their display field from the related model).
11
+ *
12
+ * Scalar types map to entity field types; `@id` marks the key; a missing `?`
13
+ * means required; `enum` blocks become enum fields; and a `@relation(fields:
14
+ * [fk], references: [id])` navigation turns the scalar `fk` column into a
15
+ * relation field with a searchable lookup.
16
+ */
17
+ import { linkRelationLabels, type EntityFieldType, type EntitySchema } from '../schema.js'
18
+ import { buildEntitySchema, type IntrospectedColumn } from '../sources/schema-from-columns.js'
19
+
20
+ /** Prisma scalar type -> entity field type. Unknown scalars fall back to text. */
21
+ const PRISMA_TYPE_MAP: Record<string, EntityFieldType> = {
22
+ Int: 'number',
23
+ BigInt: 'number',
24
+ Float: 'number',
25
+ Decimal: 'number',
26
+ String: 'text',
27
+ Boolean: 'boolean',
28
+ DateTime: 'datetime',
29
+ Json: 'json',
30
+ Bytes: 'text',
31
+ }
32
+
33
+ type PrismaModel = { name: string; body: string }
34
+ type Relations = Map<string, { table: string; column: string }>
35
+
36
+ /** Find each `model X { ... }` / `enum X { ... }` block and return its inner body. */
37
+ function blocks(source: string, keyword: 'model' | 'enum'): Array<{ name: string; body: string }> {
38
+ const out: Array<{ name: string; body: string }> = []
39
+ const re = new RegExp(`${keyword}\\s+(\\w+)\\s*\\{`, 'g')
40
+ let m: RegExpExecArray | null
41
+ while ((m = re.exec(source)) !== null) {
42
+ const open = m.index + m[0].length - 1
43
+ let depth = 0
44
+ for (let i = open; i < source.length; i++) {
45
+ const ch = source[i]
46
+ if (ch === '{') depth++
47
+ else if (ch === '}' && --depth === 0) {
48
+ out.push({ name: m[1]!, body: source.slice(open + 1, i) })
49
+ re.lastIndex = i
50
+ break
51
+ }
52
+ }
53
+ }
54
+ return out
55
+ }
56
+
57
+ /** Strip comments and blank lines; return meaningful field/attribute lines. */
58
+ function lines(body: string): string[] {
59
+ return body
60
+ .split('\n')
61
+ .map((l) => l.replace(/\/\/.*$/, '').trim())
62
+ .filter(Boolean)
63
+ }
64
+
65
+ /** Enum block -> its member names (one identifier per line, `@map` ignored). */
66
+ function enumValues(body: string): string[] {
67
+ return lines(body)
68
+ .map((l) => l.match(/^(\w+)/)?.[1])
69
+ .filter((v): v is string => Boolean(v))
70
+ }
71
+
72
+ /** Collect `@relation(fields: [fk], references: [ref])` FKs declared in a model. */
73
+ function relationsOf(modelLines: string[]): Relations {
74
+ const rels: Relations = new Map()
75
+ for (const line of modelLines) {
76
+ const rel = line.match(/@relation\([^)]*fields:\s*\[(\w+)\][^)]*references:\s*\[(\w+)\]/)
77
+ if (!rel) continue
78
+ const type = line.match(/^\w+\s+(\w+)/)?.[1]
79
+ if (type) rels.set(rel[1]!, { table: type, column: rel[2]! })
80
+ }
81
+ return rels
82
+ }
83
+
84
+ /** Field names of a composite key (`@@id([a, b])`), else empty. */
85
+ function compositeKey(modelLines: string[]): string[] {
86
+ for (const line of modelLines) {
87
+ const m = line.match(/^@@id\(\s*\[([^\]]*)\]/)
88
+ if (m) return m[1]!.split(',').map((s) => s.trim()).filter(Boolean)
89
+ }
90
+ return []
91
+ }
92
+
93
+ /** Parse one model body into neutral columns, skipping navigation fields. */
94
+ function columnsFor(model: PrismaModel, modelNames: Set<string>, enums: Map<string, string[]>): IntrospectedColumn[] {
95
+ const modelLines = lines(model.body)
96
+ const relations = relationsOf(modelLines)
97
+ const columns: IntrospectedColumn[] = []
98
+
99
+ for (const line of modelLines) {
100
+ if (line.startsWith('@@')) continue // block attribute (@@id, @@index, @@map, ...)
101
+ const field = line.match(/^(\w+)\s+(\w+)(\[\])?(\?)?(.*)$/)
102
+ if (!field) continue
103
+ const [, name, baseType, list, optional, attrs = ''] = field
104
+
105
+ // A navigation field (points at another model) is virtual - the stored FK
106
+ // scalar column carries the relation. Skip it (its @relation was captured).
107
+ if (modelNames.has(baseType!)) continue
108
+
109
+ const col: IntrospectedColumn = { name: name! }
110
+ if (enums.has(baseType!)) col.enumValues = enums.get(baseType!)
111
+ else col.type = PRISMA_TYPE_MAP[baseType!] ?? 'text'
112
+
113
+ if (/@id\b/.test(attrs)) col.primaryKey = true
114
+ // Required = not optional and not a list.
115
+ if (!optional && !list) col.required = true
116
+
117
+ const ref = relations.get(name!)
118
+ if (ref) col.references = { table: ref.table, column: ref.column }
119
+
120
+ columns.push(col)
121
+ }
122
+
123
+ // Composite key (`@@id([a, b])`): flag the members. buildEntitySchema keys off
124
+ // the first (a join table's screen targets its first key column).
125
+ const composite = compositeKey(modelLines)
126
+ if (composite.length && !columns.some((c) => c.primaryKey)) {
127
+ for (const c of columns) if (composite.includes(c.name)) c.primaryKey = true
128
+ }
129
+
130
+ return columns
131
+ }
132
+
133
+ function parseModels(source: string): {
134
+ models: PrismaModel[]
135
+ modelNames: Set<string>
136
+ enums: Map<string, string[]>
137
+ } {
138
+ const models = blocks(source, 'model')
139
+ const enums = new Map(blocks(source, 'enum').map((e) => [e.name, enumValues(e.body)]))
140
+ const modelNames = new Set(models.map((m) => m.name))
141
+ return { models, modelNames, enums }
142
+ }
143
+
144
+ /** Parse one Prisma model into an `EntitySchema`. Defaults to the first model. */
145
+ export function introspectPrisma(source: string, model?: string): EntitySchema {
146
+ const { models, modelNames, enums } = parseModels(source)
147
+ if (models.length === 0) {
148
+ throw new Error('introspectPrisma: no `model X { ... }` definition found in source')
149
+ }
150
+ const picked = model ? models.find((m) => m.name === model) : models[0]
151
+ if (!picked) throw new Error(`introspectPrisma: model "${model}" not found in source`)
152
+
153
+ const columns = columnsFor(picked, modelNames, enums)
154
+ if (columns.length === 0) {
155
+ throw new Error(`introspectPrisma: parsed model "${picked.name}" but found no fields`)
156
+ }
157
+ return buildEntitySchema(picked.name, columns)
158
+ }
159
+
160
+ /**
161
+ * Parse **every** model in a `schema.prisma` into a linked set of schemas.
162
+ * Foreign keys become relation fields and `linkRelationLabels` resolves each
163
+ * lookup's display field from the related model.
164
+ */
165
+ export function introspectPrismaAll(source: string): EntitySchema[] {
166
+ const { models, modelNames, enums } = parseModels(source)
167
+ if (models.length === 0) {
168
+ throw new Error('introspectPrisma: no `model X { ... }` definition found in source')
169
+ }
170
+ const schemas = models
171
+ .map((m) => ({ m, columns: columnsFor(m, modelNames, enums) }))
172
+ .filter(({ columns }) => columns.length > 0)
173
+ .map(({ m, columns }) => buildEntitySchema(m.name, columns))
174
+ return linkRelationLabels(schemas)
175
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { inferType, introspectDrizzle, introspectDrizzleAll, introspectJson } from './introspect'
3
+
4
+ describe('inferType', () => {
5
+ it('classifies runtime values', () => {
6
+ expect(inferType(true)).toBe('boolean')
7
+ expect(inferType(42)).toBe('number')
8
+ expect(inferType('hello')).toBe('text')
9
+ expect(inferType('2026-06-21')).toBe('dateString')
10
+ expect(inferType('2026-06-21T09:30:00Z')).toBe('datetime')
11
+ expect(inferType({ a: 1 })).toBe('json')
12
+ expect(inferType(null)).toBe('text')
13
+ })
14
+ })
15
+
16
+ describe('introspectJson', () => {
17
+ it('unions keys and infers types from the first non-null value', () => {
18
+ const schema = introspectJson('customers', [
19
+ { id: '1', name: 'Ann', age: 30, active: true },
20
+ { id: '2', name: 'Bob', age: null, joined: '2026-01-02' },
21
+ ])
22
+ const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
23
+ expect(schema.name).toBe('customers')
24
+ expect(byField.name!.type).toBe('text')
25
+ expect(byField.age!.type).toBe('number')
26
+ expect(byField.active!.type).toBe('boolean')
27
+ expect(byField.joined!.type).toBe('dateString')
28
+ })
29
+
30
+ it('marks an `id` field as the read-only primary key', () => {
31
+ const schema = introspectJson('t', [{ id: 'x', v: 1 }])
32
+ const id = schema.fields.find((f) => f.field === 'id')!
33
+ expect(id.primaryKey).toBe(true)
34
+ expect(id.readonly).toBe(true)
35
+ })
36
+ })
37
+
38
+ describe('introspectDrizzle', () => {
39
+ const source = `
40
+ import { pgTable, serial, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'
41
+
42
+ export const users = pgTable('users', {
43
+ id: serial('id').primaryKey(),
44
+ name: text('name').notNull(),
45
+ age: integer('age'),
46
+ active: boolean('active').default(false),
47
+ createdAt: timestamp('created_at').defaultNow(),
48
+ meta: jsonb('meta'),
49
+ })
50
+ `
51
+
52
+ it('parses the table name and fields with types', () => {
53
+ const schema = introspectDrizzle(source)
54
+ expect(schema.name).toBe('users')
55
+ const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
56
+ expect(byField.id!.type).toBe('number')
57
+ expect(byField.name!.type).toBe('text')
58
+ expect(byField.age!.type).toBe('number')
59
+ expect(byField.active!.type).toBe('boolean')
60
+ expect(byField.createdAt!.type).toBe('datetime')
61
+ expect(byField.meta!.type).toBe('json')
62
+ })
63
+
64
+ it('reads primaryKey and notNull modifiers', () => {
65
+ const schema = introspectDrizzle(source)
66
+ const byField = Object.fromEntries(schema.fields.map((f) => [f.field, f]))
67
+ expect(byField.id!.primaryKey).toBe(true)
68
+ expect(byField.id!.readonly).toBe(true)
69
+ expect(byField.name!.required).toBe(true)
70
+ expect(byField.age!.required).toBeUndefined()
71
+ })
72
+
73
+ it('works for sqlite/mysql table builders too', () => {
74
+ const s = introspectDrizzle(`export const t = sqliteTable('todos', { id: integer('id').primaryKey(), title: text('title') })`)
75
+ expect(s.name).toBe('todos')
76
+ expect(s.fields.map((f) => f.field)).toEqual(['id', 'title'])
77
+ })
78
+
79
+ it('throws when there is no table definition', () => {
80
+ expect(() => introspectDrizzle('const x = 1')).toThrow(/no .*Table/)
81
+ })
82
+
83
+ it('reads a composite primary key from the table config (join table)', () => {
84
+ const s = introspectDrizzle(
85
+ `
86
+ export const postsToTags = pgTable('posts_to_tags', {
87
+ postId: integer('post_id').references(() => posts.id).notNull(),
88
+ tagId: integer('tag_id').references(() => tags.id).notNull(),
89
+ }, (t) => ({ pk: primaryKey({ columns: [t.postId, t.tagId] }) }))
90
+ `,
91
+ 'posts_to_tags',
92
+ )
93
+ expect(s.idField).toBe('postId') // first composite member is the key
94
+ // The other member stays a relation lookup.
95
+ expect(s.fields.find((f) => f.field === 'tagId')?.type).toBe('relation')
96
+ })
97
+
98
+ it('reads an enum column (pgEnum + inline) as an enum field with options', () => {
99
+ const s = introspectDrizzle(`
100
+ export const roleEnum = pgEnum('role', ['admin', 'user'])
101
+ export const members = pgTable('members', {
102
+ id: serial('id').primaryKey(),
103
+ role: roleEnum('role').notNull(),
104
+ status: text('status', { enum: ['active', 'paused'] }),
105
+ })
106
+ `)
107
+ const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
108
+ expect(byField.role!.type).toBe('enum')
109
+ expect(byField.role!.options?.map((o) => o.value)).toEqual(['admin', 'user'])
110
+ expect(byField.status!.type).toBe('enum')
111
+ expect(byField.status!.options?.map((o) => o.value)).toEqual(['active', 'paused'])
112
+ })
113
+
114
+ it('captures the DB column name when it differs from the property key', () => {
115
+ const s = introspectDrizzle(`
116
+ export const users = pgTable('users', {
117
+ id: serial('id').primaryKey(),
118
+ createdAt: timestamp('created_at').defaultNow(),
119
+ name: text('name'),
120
+ })
121
+ `)
122
+ const byField = Object.fromEntries(s.fields.map((f) => [f.field, f]))
123
+ expect(byField.createdAt!.dbColumn).toBe('created_at') // renamed -> captured
124
+ expect(byField.name!.dbColumn).toBeUndefined() // same -> omitted
125
+ expect(byField.id!.dbColumn).toBeUndefined()
126
+ })
127
+
128
+ it('turns a `.references()` column into a relation field', () => {
129
+ const s = introspectDrizzle(
130
+ `
131
+ export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name') })
132
+ export const posts = pgTable('posts', {
133
+ id: serial('id').primaryKey(),
134
+ authorId: integer('author_id').references(() => users.id).notNull(),
135
+ })
136
+ `,
137
+ 'posts',
138
+ )
139
+ const author = s.fields.find((f) => f.field === 'authorId')!
140
+ expect(author.type).toBe('relation')
141
+ expect(author.relation?.entity).toBe('users')
142
+ expect(author.required).toBe(true)
143
+ })
144
+ })
145
+
146
+ describe('introspectDrizzleAll', () => {
147
+ const source = `
148
+ export const users = pgTable('users', {
149
+ id: serial('id').primaryKey(),
150
+ name: text('name').notNull(),
151
+ })
152
+ export const posts = pgTable('posts', {
153
+ id: serial('id').primaryKey(),
154
+ title: text('title').notNull(),
155
+ authorId: integer('author_id').references(() => users.id),
156
+ })
157
+ `
158
+
159
+ it('returns every table as a schema', () => {
160
+ const all = introspectDrizzleAll(source)
161
+ expect(all.map((s) => s.name)).toEqual(['users', 'posts'])
162
+ })
163
+
164
+ it('links the relation label field from the related table', () => {
165
+ const posts = introspectDrizzleAll(source).find((s) => s.name === 'posts')!
166
+ const rel = posts.fields.find((f) => f.field === 'authorId')!
167
+ expect(rel.type).toBe('relation')
168
+ expect(rel.relation?.entity).toBe('users')
169
+ // `users` has a `name` column, so the lookup shows it.
170
+ expect(rel.relation?.labelField).toBe('name')
171
+ })
172
+ })