@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,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
+ })