@svgrid/enterprise 1.2.0 → 2.0.0
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/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Introspection - turn a data source into an `EntitySchema`. Entry points:
|
|
3
|
+
*
|
|
4
|
+
* - `introspectJson(name, rows)` infers fields + types from sample JSON rows.
|
|
5
|
+
* - `introspectDrizzle(source, table?)` parses one Drizzle table (the string
|
|
6
|
+
* contents of a `schema.ts`) into an `EntitySchema`, relations and enums
|
|
7
|
+
* included.
|
|
8
|
+
* - `introspectDrizzleAll(source)` parses **every** table in the file into a
|
|
9
|
+
* linked set of schemas (foreign keys become relation fields, so the whole
|
|
10
|
+
* app scaffolds with working lookups).
|
|
11
|
+
*
|
|
12
|
+
* All are pure and best-effort: the Studio generator (and the AI host) treats
|
|
13
|
+
* the result as a draft the user can refine in `SvSchemaDesigner` before
|
|
14
|
+
* scaffolding code. Nothing imports Drizzle - the reader is a text parse, so
|
|
15
|
+
* there's no dependency and no code execution.
|
|
16
|
+
*/
|
|
17
|
+
import { linkRelationLabels, type EntityField, type EntityFieldType, type EntitySchema } from '../schema.js'
|
|
18
|
+
import { buildEntitySchema, type IntrospectedColumn } from '../sources/schema-from-columns.js'
|
|
19
|
+
import { refineFields } from '../sources/field-inference.js'
|
|
20
|
+
|
|
21
|
+
/** Infer an entity field type from a runtime JSON value. */
|
|
22
|
+
export function inferType(value: unknown): EntityFieldType {
|
|
23
|
+
if (typeof value === 'boolean') return 'boolean'
|
|
24
|
+
if (typeof value === 'number') return 'number'
|
|
25
|
+
if (value !== null && typeof value === 'object') return 'json'
|
|
26
|
+
if (typeof value === 'string') {
|
|
27
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) return 'datetime'
|
|
28
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return 'dateString'
|
|
29
|
+
return 'text'
|
|
30
|
+
}
|
|
31
|
+
return 'text'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build a schema from sample rows: union of keys, type from the first non-null value. */
|
|
35
|
+
export function introspectJson(
|
|
36
|
+
name: string,
|
|
37
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
38
|
+
): EntitySchema {
|
|
39
|
+
const keys: string[] = []
|
|
40
|
+
const seen = new Set<string>()
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
for (const k of Object.keys(row)) {
|
|
43
|
+
if (!seen.has(k)) {
|
|
44
|
+
seen.add(k)
|
|
45
|
+
keys.push(k)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fields: EntityField[] = keys.map((k) => {
|
|
51
|
+
let type: EntityFieldType = 'text'
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const v = row[k]
|
|
54
|
+
if (v != null) {
|
|
55
|
+
type = inferType(v)
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const field: EntityField = { field: k, type }
|
|
60
|
+
if (k === 'id') {
|
|
61
|
+
field.primaryKey = true
|
|
62
|
+
field.readonly = true
|
|
63
|
+
}
|
|
64
|
+
return field
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return refineFields({ name, fields })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Drizzle column builder -> entity field type. */
|
|
71
|
+
const DRIZZLE_TYPE_MAP: Record<string, EntityFieldType> = {
|
|
72
|
+
serial: 'number',
|
|
73
|
+
bigserial: 'number',
|
|
74
|
+
smallserial: 'number',
|
|
75
|
+
integer: 'number',
|
|
76
|
+
int: 'number',
|
|
77
|
+
tinyint: 'number',
|
|
78
|
+
smallint: 'number',
|
|
79
|
+
bigint: 'number',
|
|
80
|
+
numeric: 'number',
|
|
81
|
+
decimal: 'number',
|
|
82
|
+
real: 'number',
|
|
83
|
+
double: 'number',
|
|
84
|
+
doublePrecision: 'number',
|
|
85
|
+
float: 'number',
|
|
86
|
+
boolean: 'boolean',
|
|
87
|
+
text: 'text',
|
|
88
|
+
varchar: 'text',
|
|
89
|
+
char: 'text',
|
|
90
|
+
uuid: 'text',
|
|
91
|
+
timestamp: 'datetime',
|
|
92
|
+
datetime: 'datetime',
|
|
93
|
+
date: 'dateString',
|
|
94
|
+
time: 'text',
|
|
95
|
+
json: 'json',
|
|
96
|
+
jsonb: 'json',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A Drizzle table as found in source: variable name, SQL name, columns body, and extra-config text. */
|
|
100
|
+
type DrizzleTable = { varName: string; name: string; body: string; extras: string }
|
|
101
|
+
|
|
102
|
+
/** Whole-file context needed to resolve a column: table vars and enum values. */
|
|
103
|
+
type DrizzleContext = {
|
|
104
|
+
/** Table variable (`export const users = ...`) -> SQL table name. */
|
|
105
|
+
varToTable: Map<string, string>
|
|
106
|
+
/** Enum variable (`pgEnum('role', [...])`) -> allowed values. */
|
|
107
|
+
enums: Map<string, string[]>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read string literals out of a `['a', 'b']`-style array fragment. */
|
|
111
|
+
function stringLiterals(fragment: string): string[] {
|
|
112
|
+
const out: string[] = []
|
|
113
|
+
const re = /['"]([^'"]+)['"]/g
|
|
114
|
+
let m: RegExpExecArray | null
|
|
115
|
+
while ((m = re.exec(fragment)) !== null) out.push(m[1]!)
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Find the balanced `{ ... }` body starting at (or after) `from`. Returns body + end index. */
|
|
120
|
+
function balancedObject(source: string, from: number): { body: string; end: number } {
|
|
121
|
+
const open = source.indexOf('{', from)
|
|
122
|
+
if (open === -1) return { body: '', end: from }
|
|
123
|
+
let depth = 0
|
|
124
|
+
for (let i = open; i < source.length; i++) {
|
|
125
|
+
const ch = source[i]
|
|
126
|
+
if (ch === '{') depth++
|
|
127
|
+
else if (ch === '}' && --depth === 0) return { body: source.slice(open + 1, i), end: i }
|
|
128
|
+
}
|
|
129
|
+
return { body: '', end: source.length }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Split an object body at top-level commas, ignoring commas nested in (), [], {}, or strings. */
|
|
133
|
+
function splitTopLevel(body: string): string[] {
|
|
134
|
+
const parts: string[] = []
|
|
135
|
+
let depth = 0
|
|
136
|
+
let start = 0
|
|
137
|
+
let str: string | null = null
|
|
138
|
+
for (let i = 0; i < body.length; i++) {
|
|
139
|
+
const ch = body[i]
|
|
140
|
+
if (str) {
|
|
141
|
+
if (ch === str && body[i - 1] !== '\\') str = null
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (ch === "'" || ch === '"' || ch === '`') str = ch
|
|
145
|
+
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
146
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
147
|
+
else if (ch === ',' && depth === 0) {
|
|
148
|
+
parts.push(body.slice(start, i))
|
|
149
|
+
start = i + 1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const last = body.slice(start)
|
|
153
|
+
if (last.trim()) parts.push(last)
|
|
154
|
+
return parts
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Locate every `const <var> = xxxTable('name', { ... })` in the source. */
|
|
158
|
+
function findTables(source: string): DrizzleTable[] {
|
|
159
|
+
const tables: DrizzleTable[] = []
|
|
160
|
+
const re = /const\s+(\w+)\s*=\s*(\w*[Tt]able)\(\s*['"]([^'"]+)['"]\s*,/g
|
|
161
|
+
let m: RegExpExecArray | null
|
|
162
|
+
while ((m = re.exec(source)) !== null) {
|
|
163
|
+
const { body, end } = balancedObject(source, m.index + m[0].length)
|
|
164
|
+
if (body) {
|
|
165
|
+
// Capture the table's second argument (indexes, composite primary key)
|
|
166
|
+
// by scanning to the matching close paren of the `xxxTable(` call.
|
|
167
|
+
let depth = 1
|
|
168
|
+
let str: string | null = null
|
|
169
|
+
let i = end + 1
|
|
170
|
+
for (; i < source.length; i++) {
|
|
171
|
+
const ch = source[i]
|
|
172
|
+
if (str) {
|
|
173
|
+
if (ch === str && source[i - 1] !== '\\') str = null
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (ch === "'" || ch === '"' || ch === '`') str = ch
|
|
177
|
+
else if (ch === '(') depth++
|
|
178
|
+
else if (ch === ')' && --depth === 0) break
|
|
179
|
+
}
|
|
180
|
+
tables.push({ varName: m[1]!, name: m[3]!, body, extras: source.slice(end + 1, i) })
|
|
181
|
+
re.lastIndex = i
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return tables
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Field names of a Drizzle composite key (`primaryKey({ columns: [t.a, t.b] })`), else empty. */
|
|
188
|
+
function drizzleComposite(extras: string): string[] {
|
|
189
|
+
const m = extras.match(/primaryKey\(([\s\S]*?)\)/)
|
|
190
|
+
if (!m) return []
|
|
191
|
+
return [...m[1]!.matchAll(/\.(\w+)/g)].map((x) => x[1]!)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Build the whole-file context (table vars + enum declarations) once. */
|
|
195
|
+
function scanContext(source: string, tables: DrizzleTable[]): DrizzleContext {
|
|
196
|
+
const varToTable = new Map(tables.map((t) => [t.varName, t.name]))
|
|
197
|
+
const enums = new Map<string, string[]>()
|
|
198
|
+
// const roleEnum = pgEnum('role', ['admin', 'user']) (also mysqlEnum / singlestoreEnum)
|
|
199
|
+
const re = /const\s+(\w+)\s*=\s*(?:pg|mysql|singlestore)Enum\(\s*['"][^'"]+['"]\s*,\s*\[([^\]]*)\]/g
|
|
200
|
+
let m: RegExpExecArray | null
|
|
201
|
+
while ((m = re.exec(source)) !== null) enums.set(m[1]!, stringLiterals(m[2]!))
|
|
202
|
+
return { varToTable, enums }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Parse one column entry (`key: builder(args)<modifiers>`) into a neutral column. */
|
|
206
|
+
function parseColumn(entry: string, ctx: DrizzleContext): IntrospectedColumn | null {
|
|
207
|
+
const head = entry.match(/^\s*["']?(\w+)["']?\s*:\s*(\w+)\s*\(/)
|
|
208
|
+
if (!head) return null
|
|
209
|
+
const name = head[1]!
|
|
210
|
+
const builder = head[2]!
|
|
211
|
+
// Balanced args of the builder call, then the trailing modifier chain.
|
|
212
|
+
const argStart = head.index! + head[0].length - 1
|
|
213
|
+
let depth = 0
|
|
214
|
+
let argEnd = argStart
|
|
215
|
+
for (let i = argStart; i < entry.length; i++) {
|
|
216
|
+
const ch = entry[i]
|
|
217
|
+
if (ch === '(') depth++
|
|
218
|
+
else if (ch === ')' && --depth === 0) {
|
|
219
|
+
argEnd = i
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const args = entry.slice(argStart + 1, argEnd)
|
|
224
|
+
const modifiers = entry.slice(argEnd + 1)
|
|
225
|
+
|
|
226
|
+
const col: IntrospectedColumn = { name }
|
|
227
|
+
|
|
228
|
+
// Capture the DB column name (the builder's first string arg). When it differs
|
|
229
|
+
// from the property key, record it so SQL adapters use the real column.
|
|
230
|
+
const dbColumn = stringLiterals(args)[0]
|
|
231
|
+
if (dbColumn && dbColumn !== name) col.dbColumn = dbColumn
|
|
232
|
+
|
|
233
|
+
// Enum: a `pgEnum` builder var, an inline `mysqlEnum('col', ['a','b'])`, or a
|
|
234
|
+
// `{ enum: ['a','b'] }` option on a text/varchar column.
|
|
235
|
+
const inlineEnum = args.match(/enum\s*:\s*\[([^\]]*)\]/)
|
|
236
|
+
if (ctx.enums.has(builder)) col.enumValues = ctx.enums.get(builder)
|
|
237
|
+
else if (inlineEnum) col.enumValues = stringLiterals(inlineEnum[1]!)
|
|
238
|
+
else if (/enum$/i.test(builder)) {
|
|
239
|
+
const arr = args.match(/\[([^\]]*)\]/)
|
|
240
|
+
if (arr) col.enumValues = stringLiterals(arr[1]!)
|
|
241
|
+
}
|
|
242
|
+
if (!col.enumValues) col.type = DRIZZLE_TYPE_MAP[builder] ?? 'text'
|
|
243
|
+
|
|
244
|
+
if (/\.primaryKey\s*\(/.test(modifiers)) col.primaryKey = true
|
|
245
|
+
if (/\.notNull\s*\(/.test(modifiers)) col.required = true
|
|
246
|
+
|
|
247
|
+
// Foreign key: `.references(() => otherTable.col)` -> a relation field.
|
|
248
|
+
const ref = modifiers.match(/\.references\(\s*\(\)\s*=>\s*(\w+)\.(\w+)/)
|
|
249
|
+
if (ref) {
|
|
250
|
+
col.references = { table: ctx.varToTable.get(ref[1]!) ?? ref[1]!, column: ref[2]! }
|
|
251
|
+
}
|
|
252
|
+
return col
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Assemble the neutral column list for one table. */
|
|
256
|
+
function columnsFor(table: DrizzleTable, ctx: DrizzleContext): IntrospectedColumn[] {
|
|
257
|
+
const columns = splitTopLevel(table.body)
|
|
258
|
+
.map((entry) => parseColumn(entry, ctx))
|
|
259
|
+
.filter((c): c is IntrospectedColumn => c !== null)
|
|
260
|
+
|
|
261
|
+
// Composite key (`primaryKey({ columns: [t.a, t.b] })` in the table's 2nd arg):
|
|
262
|
+
// flag the members. buildEntitySchema keys off the first (a join table's screen
|
|
263
|
+
// targets its first key column).
|
|
264
|
+
const composite = drizzleComposite(table.extras)
|
|
265
|
+
if (composite.length && !columns.some((c) => c.primaryKey)) {
|
|
266
|
+
for (const c of columns) if (composite.includes(c.name)) c.primaryKey = true
|
|
267
|
+
}
|
|
268
|
+
return columns
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse one Drizzle table into an `EntitySchema`. Relations (`.references`) and
|
|
273
|
+
* enums (`pgEnum` / inline) are resolved against the whole file. Best-effort and
|
|
274
|
+
* text based: exotic column types fall back to `text`, to be corrected in the
|
|
275
|
+
* designer.
|
|
276
|
+
*/
|
|
277
|
+
export function introspectDrizzle(source: string, table?: string): EntitySchema {
|
|
278
|
+
const tables = findTables(source)
|
|
279
|
+
if (tables.length === 0) {
|
|
280
|
+
throw new Error('introspectDrizzle: no `xxxTable("name", { ... })` definition found in source')
|
|
281
|
+
}
|
|
282
|
+
const picked = table ? tables.find((t) => t.name === table || t.varName === table) : tables[0]
|
|
283
|
+
if (!picked) throw new Error(`introspectDrizzle: table "${table}" not found in source`)
|
|
284
|
+
|
|
285
|
+
const ctx = scanContext(source, tables)
|
|
286
|
+
const columns = columnsFor(picked, ctx)
|
|
287
|
+
if (columns.length === 0) {
|
|
288
|
+
throw new Error(`introspectDrizzle: parsed table "${picked.name}" but found no fields`)
|
|
289
|
+
}
|
|
290
|
+
return buildEntitySchema(picked.name, columns)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse **every** table in a Drizzle schema file into a linked set of schemas.
|
|
295
|
+
* Foreign keys become relation fields and `linkRelationLabels` resolves each
|
|
296
|
+
* lookup's display field from the actual related table - so the whole app
|
|
297
|
+
* scaffolds with working lookups.
|
|
298
|
+
*/
|
|
299
|
+
export function introspectDrizzleAll(source: string): EntitySchema[] {
|
|
300
|
+
const tables = findTables(source)
|
|
301
|
+
if (tables.length === 0) {
|
|
302
|
+
throw new Error('introspectDrizzle: no `xxxTable("name", { ... })` definition found in source')
|
|
303
|
+
}
|
|
304
|
+
const ctx = scanContext(source, tables)
|
|
305
|
+
const schemas = tables
|
|
306
|
+
.map((t) => ({ t, columns: columnsFor(t, ctx) }))
|
|
307
|
+
.filter(({ columns }) => columns.length > 0)
|
|
308
|
+
.map(({ t, columns }) => buildEntitySchema(t.name, columns))
|
|
309
|
+
return linkRelationLabels(schemas)
|
|
310
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { introspectDrizzle } from './introspect'
|
|
3
|
+
import { scaffold } from './scaffold'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The end-to-end generator flow the MCP tools expose: a Drizzle schema file
|
|
7
|
+
* becomes an EntitySchema, which becomes runnable SvelteKit files. This proves
|
|
8
|
+
* introspect output feeds scaffold cleanly.
|
|
9
|
+
*/
|
|
10
|
+
describe('introspect -> scaffold pipeline', () => {
|
|
11
|
+
const drizzle = `
|
|
12
|
+
import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core'
|
|
13
|
+
export const products = pgTable('products', {
|
|
14
|
+
id: serial('id').primaryKey(),
|
|
15
|
+
title: text('title').notNull(),
|
|
16
|
+
price: integer('price'),
|
|
17
|
+
inStock: boolean('in_stock').default(true),
|
|
18
|
+
})
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
it('drizzle source -> schema -> scaffolded files', () => {
|
|
22
|
+
const schema = introspectDrizzle(drizzle)
|
|
23
|
+
const { files } = scaffold(schema)
|
|
24
|
+
const byPath = Object.fromEntries(files.map((f) => [f.path, f]))
|
|
25
|
+
|
|
26
|
+
// schema module reflects the parsed table + fields
|
|
27
|
+
const schemaFile = byPath['src/lib/products.schema.ts']!.contents
|
|
28
|
+
expect(schemaFile).toContain('export const productsSchema: EntitySchema<ProductsRow>')
|
|
29
|
+
expect(schemaFile).toContain('title: string')
|
|
30
|
+
expect(schemaFile).toContain('price: number')
|
|
31
|
+
expect(schemaFile).toContain('inStock: boolean')
|
|
32
|
+
expect(schemaFile).toContain('field: "id", type: "number", primaryKey: true, readonly: true')
|
|
33
|
+
|
|
34
|
+
// page + server route are wired to the products endpoint
|
|
35
|
+
expect(byPath['src/routes/products/+page.svelte']!.contents).toContain(
|
|
36
|
+
"createKitDataSource<ProductsRow>({ endpoint: '/api/products' })",
|
|
37
|
+
)
|
|
38
|
+
expect(byPath['src/routes/api/products/+server.ts']!.contents).toContain(
|
|
39
|
+
'createKitHandlers({ schema: productsSchema, source })',
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robustness suite: the designer must survive extensive, adversarial editing
|
|
3
|
+
* without throwing or corrupting the model. Two guarantees:
|
|
4
|
+
*
|
|
5
|
+
* 1. `validateProject` can legitimately return DUPLICATE messages (two
|
|
6
|
+
* unconfigured master-detail blocks on one screen produce identical warnings).
|
|
7
|
+
* This is the root cause of the historical `each_key_duplicate` crash - the
|
|
8
|
+
* designer must key those lists by index, never by message. We pin the
|
|
9
|
+
* behaviour here so a future "dedupe by message" refactor can't silently
|
|
10
|
+
* reintroduce the crash surface.
|
|
11
|
+
* 2. A fuzz driver applies thousands of random ops (including invalid ids /
|
|
12
|
+
* out-of-range indices) and asserts the ops never throw and never break the
|
|
13
|
+
* model's invariants (unique block ids, unique screen ids, round-trippable).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import type { EntitySchema } from '../schema'
|
|
17
|
+
import {
|
|
18
|
+
addBlock,
|
|
19
|
+
addBlockAt,
|
|
20
|
+
addScreen,
|
|
21
|
+
addScreenFromTemplate,
|
|
22
|
+
createProject,
|
|
23
|
+
isProjectValid,
|
|
24
|
+
moveBlock,
|
|
25
|
+
parseProject,
|
|
26
|
+
removeBlock,
|
|
27
|
+
removeEntity,
|
|
28
|
+
removeScreen,
|
|
29
|
+
reorderBlock,
|
|
30
|
+
serializeProject,
|
|
31
|
+
updateBlock,
|
|
32
|
+
updateScreen,
|
|
33
|
+
validateProject,
|
|
34
|
+
type BlockKind,
|
|
35
|
+
type StudioProject,
|
|
36
|
+
} from './project'
|
|
37
|
+
|
|
38
|
+
const customers: EntitySchema = {
|
|
39
|
+
name: 'customers', label: 'Customer', idField: 'id',
|
|
40
|
+
fields: [
|
|
41
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
42
|
+
{ field: 'name', type: 'text' },
|
|
43
|
+
{ field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
|
|
44
|
+
{ field: 'mrr', type: 'number' },
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
const orders: EntitySchema = {
|
|
48
|
+
name: 'orders', label: 'Order', idField: 'id',
|
|
49
|
+
fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'total', type: 'number' }],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('validation message duplication (the each_key_duplicate root cause)', () => {
|
|
53
|
+
it('two unconfigured master-detail blocks yield IDENTICAL warning messages', () => {
|
|
54
|
+
const sid = 'customers'
|
|
55
|
+
let p = createProject([customers])
|
|
56
|
+
p = addBlock(p, sid, 'master-detail')
|
|
57
|
+
p = addBlock(p, sid, 'master-detail')
|
|
58
|
+
const warnings = validateProject(p).filter((i) => /Master\/detail/.test(i.message))
|
|
59
|
+
expect(warnings).toHaveLength(2)
|
|
60
|
+
expect(warnings[0]!.message).toBe(warnings[1]!.message) // duplicate keys IF keyed by message
|
|
61
|
+
// The designer must therefore key its issue list by index; this is a contract.
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('never throws while validating a project full of duplicate-warning blocks', () => {
|
|
65
|
+
let p = createProject([customers])
|
|
66
|
+
for (let i = 0; i < 12; i++) p = addBlock(p, 'customers', 'master-detail')
|
|
67
|
+
expect(() => validateProject(p)).not.toThrow()
|
|
68
|
+
expect(validateProject(p).filter((i) => /Master\/detail/.test(i.message))).toHaveLength(12)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('ops are total: invalid targets are no-ops, not throws', () => {
|
|
73
|
+
const p = createProject([customers, orders])
|
|
74
|
+
const sid = p.screens[0]!.id
|
|
75
|
+
const bid = p.screens[0]!.blocks[0]!.id
|
|
76
|
+
|
|
77
|
+
it('tolerates unknown screen / block ids', () => {
|
|
78
|
+
expect(() => addBlock(p, 'nope', 'grid')).not.toThrow()
|
|
79
|
+
expect(removeBlock(p, sid, 'ghost')).toEqual(p)
|
|
80
|
+
expect(removeBlock(p, 'ghost', bid)).toEqual(p)
|
|
81
|
+
expect(moveBlock(p, sid, 'ghost', 1)).toEqual(p)
|
|
82
|
+
expect(reorderBlock(p, sid, 'ghost', 0)).toEqual(p)
|
|
83
|
+
expect(updateBlock(p, sid, 'ghost', { span: 2 })).toEqual(p)
|
|
84
|
+
expect(updateScreen(p, 'ghost', { title: 'x' })).toEqual(p)
|
|
85
|
+
expect(removeScreen(p, 'ghost')).toEqual(p)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('clamps out-of-range indices instead of tearing the array', () => {
|
|
89
|
+
expect(addBlockAt(p, sid, 'kpi', -5).screens[0]!.blocks[0]!.config.kind).toBe('kpi')
|
|
90
|
+
expect(addBlockAt(p, sid, 'kpi', 999).screens[0]!.blocks.at(-1)!.config.kind).toBe('kpi')
|
|
91
|
+
expect(reorderBlock(p, sid, bid, -10).screens[0]!.blocks[0]!.id).toBe(bid)
|
|
92
|
+
expect(reorderBlock(p, sid, bid, 999).screens[0]!.blocks.at(-1)!.id).toBe(bid)
|
|
93
|
+
expect(moveBlock(p, sid, bid, -1)).toEqual(p) // already first
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('removing an entity drops its screens and never orphans a screen without validation catching it', () => {
|
|
97
|
+
const gone = removeEntity(createProject([customers, orders]), 'orders')
|
|
98
|
+
expect(gone.screens.every((s) => s.entity !== 'orders')).toBe(true)
|
|
99
|
+
expect(validateProject(gone).some((i) => /missing entity/.test(i.message))).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// A tiny seeded PRNG so a failing fuzz run is reproducible.
|
|
104
|
+
function lcg(seed: number): () => number {
|
|
105
|
+
let s = seed >>> 0
|
|
106
|
+
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function invariants(p: StudioProject): void {
|
|
110
|
+
// Screen ids unique.
|
|
111
|
+
const sids = p.screens.map((s) => s.id)
|
|
112
|
+
expect(new Set(sids).size).toBe(sids.length)
|
|
113
|
+
for (const s of p.screens) {
|
|
114
|
+
// Block ids unique within a screen (the crash-adjacent invariant).
|
|
115
|
+
const bids = s.blocks.map((b) => b.id)
|
|
116
|
+
expect(new Set(bids).size).toBe(bids.length)
|
|
117
|
+
for (const b of s.blocks) expect([1, 2, 3]).toContain(b.span)
|
|
118
|
+
}
|
|
119
|
+
// Always serializable + round-trippable.
|
|
120
|
+
expect(() => parseProject(serializeProject(p))).not.toThrow()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe('fuzz: thousands of random ops never throw and preserve invariants', () => {
|
|
124
|
+
const KINDS: BlockKind[] = ['grid', 'form', 'chart', 'dashboard', 'kpi', 'master-detail', 'lookup']
|
|
125
|
+
|
|
126
|
+
it.each([1, 7, 42, 1337, 99999])('seed %i', (seed) => {
|
|
127
|
+
const rnd = lcg(seed)
|
|
128
|
+
const pick = <T>(xs: readonly T[]): T => xs[Math.floor(rnd() * xs.length)]!
|
|
129
|
+
let p = createProject([customers, orders], { title: 'Fuzz' })
|
|
130
|
+
|
|
131
|
+
for (let step = 0; step < 400; step++) {
|
|
132
|
+
const screen = p.screens.length ? pick(p.screens) : null
|
|
133
|
+
// Occasionally target a bogus id to exercise the no-op paths.
|
|
134
|
+
const sid = !screen || rnd() < 0.1 ? 'ghost-screen' : screen.id
|
|
135
|
+
const block = screen && screen.blocks.length ? pick(screen.blocks) : null
|
|
136
|
+
const bid = !block || rnd() < 0.1 ? 'ghost-block' : block.id
|
|
137
|
+
const op = Math.floor(rnd() * 10)
|
|
138
|
+
expect(() => {
|
|
139
|
+
switch (op) {
|
|
140
|
+
case 0: p = addBlock(p, sid, pick(KINDS)); break
|
|
141
|
+
case 1: p = addBlockAt(p, sid, pick(KINDS), Math.floor((rnd() - 0.2) * 12)); break
|
|
142
|
+
case 2: p = removeBlock(p, sid, bid); break
|
|
143
|
+
case 3: p = moveBlock(p, sid, bid, rnd() < 0.5 ? -1 : 1); break
|
|
144
|
+
case 4: p = reorderBlock(p, sid, bid, Math.floor((rnd() - 0.2) * 12)); break
|
|
145
|
+
case 5: p = updateBlock(p, sid, bid, { span: pick([1, 2, 3] as const) }); break
|
|
146
|
+
case 6: p = addScreen(p, pick(['customers', 'orders', 'ghost'])); break
|
|
147
|
+
case 7: p = removeScreen(p, sid); break
|
|
148
|
+
case 8: p = addScreenFromTemplate(p, pick(['customers', 'orders']), pick(['crud', 'dashboard', 'master-detail'] as const)); break
|
|
149
|
+
case 9: p = updateScreen(p, sid, { title: `T${step}` }); break
|
|
150
|
+
}
|
|
151
|
+
}).not.toThrow()
|
|
152
|
+
expect(() => validateProject(p)).not.toThrow()
|
|
153
|
+
expect(typeof isProjectValid(p)).toBe('boolean')
|
|
154
|
+
invariants(p)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
})
|