@svgrid/enterprise 2.3.1 → 2.3.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.
Files changed (48) hide show
  1. package/dist/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
  2. package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
  3. package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
  4. package/dist/cdn/svgrid-enterprise.svelte-external.js +7540 -7511
  5. package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
  6. package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
  7. package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
  8. package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
  9. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
  10. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
  11. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
  12. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
  13. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
  14. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
  15. package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
  16. package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
  17. package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
  18. package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
  19. package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
  20. package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
  21. package/dist/designer/assets/easing-zaypKrr0.js +3 -0
  22. package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
  23. package/dist/designer/assets/{index-DsDgp9Xq.js → index-DVrv9dH6.js} +141 -3102
  24. package/dist/designer/assets/index-DjPp1mn7.css +1 -0
  25. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
  26. package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
  27. package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
  28. package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
  29. package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  30. package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
  31. package/dist/designer/assets/src-BRKu53tV.js +2842 -0
  32. package/dist/designer/assets/src-DseB1VRN.css +1 -0
  33. package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
  34. package/dist/designer/index.html +21 -2
  35. package/dist/node/studio.js +220 -27
  36. package/package.json +5 -5
  37. package/src/sources/introspect-supabase.ts +2 -2
  38. package/src/studio/emit-project.ts +1 -1
  39. package/src/studio/init-flow.test.ts +364 -239
  40. package/src/studio/init-flow.ts +409 -358
  41. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +0 -7
  42. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +0 -10
  43. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +0 -1
  44. package/dist/designer/assets/index-tTY_Dx4P.css +0 -1
  45. package/dist/designer/assets/jszip.min-fkJdmAmj.js +0 -2
  46. package/dist/designer/assets/pdfmake-DeCsnyl9.js +0 -242
  47. package/dist/designer/assets/smart.export-BZlSCE8T.js +0 -35
  48. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +0 -1
@@ -1,239 +1,364 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
3
- import type { StudioIO } from './cli.js'
4
- import { parseProject, validateProject, type GridConfig } from './project.js'
5
-
6
- /** A scripted terminal: answers are consumed in order, defaults fill the rest. */
7
- function scriptedPrompts(answers: string[]) {
8
- const asked: string[] = []
9
- const said: string[] = []
10
- const queue = [...answers]
11
- const io: PromptIO = {
12
- ask: async (question, def) => {
13
- asked.push(question)
14
- const next = queue.shift()
15
- return next === undefined ? (def ?? '') : next
16
- },
17
- say: (line) => said.push(line),
18
- }
19
- return { io, asked, said, get remaining() { return queue.length } }
20
- }
21
-
22
- /** An in-memory filesystem. */
23
- function memoryIo() {
24
- const files = new Map<string, string>()
25
- const io: StudioIO = {
26
- readFile: async (path) => files.get(path) ?? null,
27
- writeFile: async (path, contents) => { files.set(path, contents) },
28
- }
29
- return { io, files }
30
- }
31
-
32
- /** A fake Postgres holding two tables, answering the real catalog queries
33
- * (which alias their columns to name / type / nullable / pk). */
34
- function fakeDb(): DbGateway {
35
- const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
36
- customers: [
37
- { name: 'id', type: 'integer', nullable: 0, pk: 1 },
38
- { name: 'name', type: 'text', nullable: 0, pk: 0 },
39
- { name: 'city', type: 'text', nullable: 1, pk: 0 },
40
- ],
41
- orders: [
42
- { name: 'id', type: 'integer', nullable: 0, pk: 1 },
43
- { name: 'total', type: 'numeric', nullable: 1, pk: 0 },
44
- ],
45
- }
46
- return {
47
- ensureDriver: async () => ({ ok: true }),
48
- connect: async () => async (sql: string, params: unknown[]) => {
49
- if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
50
- if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
51
- if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
52
- return [] // no foreign keys
53
- },
54
- }
55
- }
56
-
57
- describe('parseSelection', () => {
58
- const items = ['customers', 'orders', 'invoices']
59
-
60
- it('takes everything by default', () => {
61
- expect(parseSelection('', items)).toEqual(items)
62
- expect(parseSelection('all', items)).toEqual(items)
63
- expect(parseSelection('*', items)).toEqual(items)
64
- })
65
-
66
- it('takes nothing for "none"', () => {
67
- expect(parseSelection('none', items)).toEqual([])
68
- })
69
-
70
- it('accepts names, numbers, and a mix - without duplicates', () => {
71
- expect(parseSelection('orders', items)).toEqual(['orders'])
72
- expect(parseSelection('1, 3', items)).toEqual(['customers', 'invoices'])
73
- expect(parseSelection('2, invoices', items)).toEqual(['orders', 'invoices'])
74
- expect(parseSelection('orders, 2', items)).toEqual(['orders'])
75
- })
76
-
77
- it('ignores names and numbers that do not exist', () => {
78
- expect(parseSelection('nope, 9, orders', items)).toEqual(['orders'])
79
- })
80
- })
81
-
82
- describe('runStudioInit', () => {
83
- it('builds a runnable app from a starter dataset with all defaults', async () => {
84
- const prompts = scriptedPrompts([])
85
- const fs = memoryIo()
86
- const result = await runStudioInit({ yes: true }, prompts.io, null, fs.io)
87
-
88
- expect(result.project.entities.length).toBeGreaterThanOrEqual(2)
89
- expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
90
- expect(result.written).toContain('studio.config.json')
91
- expect(result.written.some((p) => p.endsWith('package.json'))).toBe(true)
92
- expect(result.written.some((p) => p.includes('+page.svelte'))).toBe(true)
93
- // --yes asks nothing.
94
- expect(prompts.asked).toEqual([])
95
- // The written config re-parses into the same project.
96
- expect(parseProject(fs.files.get('studio.config.json')!)).toEqual(result.project)
97
- })
98
-
99
- it('asks the questions in order when driven interactively', async () => {
100
- const prompts = scriptedPrompts([
101
- '1', // source: sample data
102
- '2', // dataset: products & categories
103
- 'y', // full CRUD suite
104
- '1', // editing: popup form
105
- '1', // theme
106
- 'n', // dark mode
107
- 'Catalog', // app name
108
- '.', // out dir
109
- ])
110
- const fs = memoryIo()
111
- const result = await runStudioInit({}, prompts.io, null, fs.io)
112
-
113
- expect(prompts.asked).toEqual([
114
- 'Pick a number:', // source
115
- 'Pick a number:', // dataset
116
- '\nGenerate a list, an edit form and a record page for each table? (Y/n)',
117
- 'Pick a number:', // editing mode
118
- 'Pick a number:', // theme
119
- 'Dark mode? (y/N)',
120
- '\nApp name:',
121
- 'Write it where?',
122
- ])
123
- expect(result.project.title).toBe('Catalog')
124
- expect(result.project.entities.map((e) => e.name)).toEqual(['categories', 'products'])
125
- })
126
-
127
- it('honours the out directory', async () => {
128
- const prompts = scriptedPrompts([])
129
- const fs = memoryIo()
130
- const result = await runStudioInit({ yes: true, out: 'apps/shop' }, prompts.io, null, fs.io)
131
-
132
- expect(result.written.every((p) => p.startsWith('apps/shop/'))).toBe(true)
133
- expect(result.nextSteps[0]).toBe('cd apps/shop')
134
- expect(fs.files.has('apps/shop/studio.config.json')).toBe(true)
135
- })
136
-
137
- it('stores a dataset in PGlite when that source is picked', async () => {
138
- const prompts = scriptedPrompts(['3', '1', 'y', '1', '1', 'n', 'Shop', '.'])
139
- const fs = memoryIo()
140
- const result = await runStudioInit({}, prompts.io, null, fs.io)
141
-
142
- expect(result.project.dataSource).toBe('pglite')
143
- const first = result.project.entities[0]!.name
144
- expect(result.project.dataSources?.[first]).toMatchObject({ kind: 'pglite', table: first })
145
- // The seed rides along, so the generated app boots with data.
146
- expect((result.project.dataSources?.[first] as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
147
- })
148
-
149
- it('reads tables from a live database', async () => {
150
- const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Ops', '.'])
151
- const fs = memoryIo()
152
- const result = await runStudioInit(
153
- { db: 'postgres', url: 'postgres://localhost/app' },
154
- prompts.io,
155
- fakeDb(),
156
- fs.io,
157
- )
158
-
159
- expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
160
- expect(result.project.dataSource).toBe('sql')
161
- expect(result.project.dataSources?.customers).toEqual({ kind: 'sql', table: 'customers', dialect: 'postgres' })
162
- // --db + --url skip the connection questions entirely.
163
- expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
164
- })
165
-
166
- it('imports only the picked tables', async () => {
167
- const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Ops', '.'])
168
- const fs = memoryIo()
169
- const result = await runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io)
170
- expect(result.project.entities.map((e) => e.name)).toEqual(['orders'])
171
- })
172
-
173
- it('fails clearly when the driver cannot be installed', async () => {
174
- const prompts = scriptedPrompts(['all'])
175
- const fs = memoryIo()
176
- const db: DbGateway = { ...fakeDb(), ensureDriver: async () => ({ ok: false, message: 'pg is not installed' }) }
177
- await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, db, fs.io))
178
- .rejects.toThrow('pg is not installed')
179
- })
180
-
181
- it('fails clearly when no table is picked', async () => {
182
- const prompts = scriptedPrompts(['none'])
183
- const fs = memoryIo()
184
- await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io))
185
- .rejects.toThrow('No tables picked')
186
- })
187
-
188
- it('refuses a database when no gateway is available', async () => {
189
- const prompts = scriptedPrompts([])
190
- const fs = memoryIo()
191
- await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, null, fs.io))
192
- .rejects.toThrow('not available here')
193
- })
194
-
195
- it('rejects an unknown dataset id by name', async () => {
196
- const prompts = scriptedPrompts([])
197
- const fs = memoryIo()
198
- await expect(runStudioInit({ yes: true, dataset: 'nope' }, prompts.io, null, fs.io))
199
- .rejects.toThrow(/Unknown dataset "nope"/)
200
- })
201
-
202
- it('builds an app from a REST endpoint', async () => {
203
- const prompts = scriptedPrompts(['4', 'https://api.example.com/widgets', 'widgets', 'y', '1', '1', 'n', 'API app', '.'])
204
- const fs = memoryIo()
205
- const rows = JSON.stringify({ data: [{ id: 1, name: 'Widget', price: 9.5 }] })
206
- const result = await runStudioInit({}, prompts.io, null, fs.io, async () => rows)
207
-
208
- expect(result.project.entities.map((e) => e.name)).toEqual(['widgets'])
209
- expect(result.project.dataSources?.widgets).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com', path: 'widgets' })
210
- })
211
-
212
- it('applies the picked editing mode to the generated grids', async () => {
213
- const prompts = scriptedPrompts(['1', '1', 'y', '2', '1', 'n', 'Inline', '.'])
214
- const fs = memoryIo()
215
- const result = await runStudioInit({}, prompts.io, null, fs.io)
216
-
217
- const listScreen = result.project.screens.find((s) => s.id === result.project.entities[0]!.name)!
218
- const grid = listScreen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
219
- expect(grid.editing).toBe('inline')
220
- })
221
-
222
- it('applies the chosen theme', async () => {
223
- const prompts = scriptedPrompts([])
224
- const fs = memoryIo()
225
- const result = await runStudioInit({ yes: true, theme: 'material', dark: true }, prompts.io, null, fs.io)
226
- expect(result.project.theme?.preset).toBe('material')
227
- expect(result.project.theme?.mode).toBe('dark')
228
- })
229
-
230
- it('keeps user-owned files that already exist', async () => {
231
- const fs = memoryIo()
232
- await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
233
- const handlers = [...fs.files.keys()].find((p) => p.endsWith('handlers.ts'))
234
- if (!handlers) return // no user-owned companion in this project shape
235
- fs.files.set(handlers, '// my own code')
236
- await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
237
- expect(fs.files.get(handlers)).toBe('// my own code')
238
- })
239
- })
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
3
+ import type { StudioIO } from './cli.js'
4
+ import { parseProject, validateProject, type GridConfig } from './project.js'
5
+
6
+ /** A scripted terminal: answers are consumed in order, defaults fill the rest. */
7
+ function scriptedPrompts(answers: string[]) {
8
+ const asked: string[] = []
9
+ const said: string[] = []
10
+ const queue = [...answers]
11
+ const io: PromptIO = {
12
+ ask: async (question, def) => {
13
+ asked.push(question)
14
+ const next = queue.shift()
15
+ return next === undefined ? (def ?? '') : next
16
+ },
17
+ say: (line) => said.push(line),
18
+ }
19
+ return { io, asked, said, get remaining() { return queue.length } }
20
+ }
21
+
22
+ /** An in-memory filesystem. */
23
+ function memoryIo() {
24
+ const files = new Map<string, string>()
25
+ const io: StudioIO = {
26
+ readFile: async (path) => files.get(path) ?? null,
27
+ writeFile: async (path, contents) => { files.set(path, contents) },
28
+ }
29
+ return { io, files }
30
+ }
31
+
32
+ /** A fake Postgres holding two tables, answering the real catalog queries
33
+ * (which alias their columns to name / type / nullable / pk). */
34
+ function fakeDb(): DbGateway {
35
+ const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
36
+ customers: [
37
+ { name: 'id', type: 'integer', nullable: 0, pk: 1 },
38
+ { name: 'name', type: 'text', nullable: 0, pk: 0 },
39
+ { name: 'city', type: 'text', nullable: 1, pk: 0 },
40
+ ],
41
+ orders: [
42
+ { name: 'id', type: 'integer', nullable: 0, pk: 1 },
43
+ { name: 'total', type: 'numeric', nullable: 1, pk: 0 },
44
+ ],
45
+ }
46
+ return {
47
+ ensureDriver: async () => ({ ok: true }),
48
+ connect: async () => async (sql: string, params: unknown[]) => {
49
+ if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
50
+ if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
51
+ if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
52
+ return [] // no foreign keys
53
+ },
54
+ }
55
+ }
56
+
57
+ /** Stand in for a Supabase project: PostgREST serves its OpenAPI doc at /rest/v1/. */
58
+ function stubSupabase(definitions: Record<string, unknown> | null) {
59
+ vi.stubGlobal('fetch', async (input: string | URL) => {
60
+ const href = String(input)
61
+ if (href.endsWith('/rest/v1/')) {
62
+ if (!definitions) return new Response('nope', { status: 401 })
63
+ return new Response(JSON.stringify({ definitions }), { status: 200, headers: { 'content-type': 'application/json' } })
64
+ }
65
+ // Row-sample and CSV fallbacks: answer empty so only the OpenAPI path counts.
66
+ return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })
67
+ })
68
+ }
69
+
70
+ const SUPABASE_DEFS = {
71
+ customers: {
72
+ required: ['id', 'name'],
73
+ properties: {
74
+ id: { type: 'integer', description: 'Note:\nThis is a Primary Key.<pk/>' },
75
+ name: { type: 'string' },
76
+ tier: { type: 'string', enum: ['free', 'pro'] },
77
+ },
78
+ },
79
+ orders: {
80
+ required: ['id'],
81
+ properties: {
82
+ id: { type: 'integer', description: '<pk/>' },
83
+ total: { type: 'number' },
84
+ customer_id: { type: 'integer', description: "<fk table='customers' column='id'/>" },
85
+ },
86
+ },
87
+ }
88
+
89
+ describe('parseSelection', () => {
90
+ const items = ['customers', 'orders', 'invoices']
91
+
92
+ it('takes everything by default', () => {
93
+ expect(parseSelection('', items)).toEqual(items)
94
+ expect(parseSelection('all', items)).toEqual(items)
95
+ expect(parseSelection('*', items)).toEqual(items)
96
+ })
97
+
98
+ it('takes nothing for "none"', () => {
99
+ expect(parseSelection('none', items)).toEqual([])
100
+ })
101
+
102
+ it('accepts names, numbers, and a mix - without duplicates', () => {
103
+ expect(parseSelection('orders', items)).toEqual(['orders'])
104
+ expect(parseSelection('1, 3', items)).toEqual(['customers', 'invoices'])
105
+ expect(parseSelection('2, invoices', items)).toEqual(['orders', 'invoices'])
106
+ expect(parseSelection('orders, 2', items)).toEqual(['orders'])
107
+ })
108
+
109
+ it('ignores names and numbers that do not exist', () => {
110
+ expect(parseSelection('nope, 9, orders', items)).toEqual(['orders'])
111
+ })
112
+ })
113
+
114
+ describe('runStudioInit', () => {
115
+ it('builds a runnable app from a starter dataset with all defaults', async () => {
116
+ const prompts = scriptedPrompts([])
117
+ const fs = memoryIo()
118
+ const result = await runStudioInit({ yes: true }, prompts.io, null, fs.io)
119
+
120
+ expect(result.project.entities.length).toBeGreaterThanOrEqual(2)
121
+ expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
122
+ expect(result.written).toContain('studio.config.json')
123
+ expect(result.written.some((p) => p.endsWith('package.json'))).toBe(true)
124
+ expect(result.written.some((p) => p.includes('+page.svelte'))).toBe(true)
125
+ // --yes asks nothing.
126
+ expect(prompts.asked).toEqual([])
127
+ // The written config re-parses into the same project.
128
+ expect(parseProject(fs.files.get('studio.config.json')!)).toEqual(result.project)
129
+ })
130
+
131
+ it('asks the questions in order when driven interactively', async () => {
132
+ const prompts = scriptedPrompts([
133
+ '1', // source: sample data
134
+ '2', // dataset: products & categories
135
+ 'y', // full CRUD suite
136
+ '1', // editing: popup form
137
+ '1', // theme
138
+ 'n', // dark mode
139
+ 'Catalog', // app name
140
+ '.', // out dir
141
+ ])
142
+ const fs = memoryIo()
143
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
144
+
145
+ expect(prompts.asked).toEqual([
146
+ 'Pick a number:', // source
147
+ 'Pick a number:', // dataset
148
+ '\nGenerate a list, an edit form and a record page for each table? (Y/n)',
149
+ 'Pick a number:', // editing mode
150
+ 'Pick a number:', // theme
151
+ 'Dark mode? (y/N)',
152
+ '\nApp name:',
153
+ 'Write it where?',
154
+ ])
155
+ expect(result.project.title).toBe('Catalog')
156
+ expect(result.project.entities.map((e) => e.name)).toEqual(['categories', 'products'])
157
+ })
158
+
159
+ it('honours the out directory', async () => {
160
+ const prompts = scriptedPrompts([])
161
+ const fs = memoryIo()
162
+ const result = await runStudioInit({ yes: true, out: 'apps/shop' }, prompts.io, null, fs.io)
163
+
164
+ expect(result.written.every((p) => p.startsWith('apps/shop/'))).toBe(true)
165
+ expect(result.nextSteps[0]).toBe('cd apps/shop')
166
+ expect(fs.files.has('apps/shop/studio.config.json')).toBe(true)
167
+ })
168
+
169
+ it('stores a dataset in PGlite when that source is picked', async () => {
170
+ // Source menu: 1 sample, 2 database, 3 supabase, 4 pglite, 5 rest.
171
+ const prompts = scriptedPrompts(['4', '1', 'y', '1', '1', 'n', 'Shop', '.'])
172
+ const fs = memoryIo()
173
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
174
+
175
+ expect(result.project.dataSource).toBe('pglite')
176
+ const first = result.project.entities[0]!.name
177
+ expect(result.project.dataSources?.[first]).toMatchObject({ kind: 'pglite', table: first })
178
+ // The seed rides along, so the generated app boots with data.
179
+ expect((result.project.dataSources?.[first] as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
180
+ })
181
+
182
+ it('reads tables from a live database', async () => {
183
+ const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Ops', '.'])
184
+ const fs = memoryIo()
185
+ const result = await runStudioInit(
186
+ { db: 'postgres', url: 'postgres://localhost/app' },
187
+ prompts.io,
188
+ fakeDb(),
189
+ fs.io,
190
+ )
191
+
192
+ expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
193
+ expect(result.project.dataSource).toBe('sql')
194
+ expect(result.project.dataSources?.customers).toEqual({ kind: 'sql', table: 'customers', dialect: 'postgres' })
195
+ // --db + --url skip the connection questions entirely.
196
+ expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
197
+ })
198
+
199
+ it('imports only the picked tables', async () => {
200
+ const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Ops', '.'])
201
+ const fs = memoryIo()
202
+ const result = await runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io)
203
+ expect(result.project.entities.map((e) => e.name)).toEqual(['orders'])
204
+ })
205
+
206
+ it('fails clearly when the driver cannot be installed', async () => {
207
+ const prompts = scriptedPrompts(['all'])
208
+ const fs = memoryIo()
209
+ const db: DbGateway = { ...fakeDb(), ensureDriver: async () => ({ ok: false, message: 'pg is not installed' }) }
210
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, db, fs.io))
211
+ .rejects.toThrow('pg is not installed')
212
+ })
213
+
214
+ it('fails clearly when no table is picked', async () => {
215
+ const prompts = scriptedPrompts(['none'])
216
+ const fs = memoryIo()
217
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io))
218
+ .rejects.toThrow('No tables picked')
219
+ })
220
+
221
+ it('refuses a database when no gateway is available', async () => {
222
+ const prompts = scriptedPrompts([])
223
+ const fs = memoryIo()
224
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, null, fs.io))
225
+ .rejects.toThrow('not available here')
226
+ })
227
+
228
+ it('rejects an unknown dataset id by name', async () => {
229
+ const prompts = scriptedPrompts([])
230
+ const fs = memoryIo()
231
+ await expect(runStudioInit({ yes: true, dataset: 'nope' }, prompts.io, null, fs.io))
232
+ .rejects.toThrow(/Unknown dataset "nope"/)
233
+ })
234
+
235
+ it('builds an app from a REST endpoint', async () => {
236
+ const prompts = scriptedPrompts(['5', 'https://api.example.com/widgets', 'widgets', 'y', '1', '1', 'n', 'API app', '.'])
237
+ const fs = memoryIo()
238
+ const rows = JSON.stringify({ data: [{ id: 1, name: 'Widget', price: 9.5 }] })
239
+ const result = await runStudioInit({}, prompts.io, null, fs.io, async () => rows)
240
+
241
+ expect(result.project.entities.map((e) => e.name)).toEqual(['widgets'])
242
+ expect(result.project.dataSources?.widgets).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com', path: 'widgets' })
243
+ })
244
+
245
+ it('applies the picked editing mode to the generated grids', async () => {
246
+ const prompts = scriptedPrompts(['1', '1', 'y', '2', '1', 'n', 'Inline', '.'])
247
+ const fs = memoryIo()
248
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
249
+
250
+ const listScreen = result.project.screens.find((s) => s.id === result.project.entities[0]!.name)!
251
+ const grid = listScreen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
252
+ expect(grid.editing).toBe('inline')
253
+ })
254
+
255
+ describe('supabase', () => {
256
+ afterEach(() => vi.unstubAllGlobals())
257
+
258
+ it('reads a project over the REST API and binds each table to it', async () => {
259
+ stubSupabase(SUPABASE_DEFS)
260
+ const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
261
+ const fs = memoryIo()
262
+ const result = await runStudioInit(
263
+ { supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'anon-key' },
264
+ prompts.io,
265
+ null, // no DbGateway: the point is that Supabase needs no driver
266
+ fs.io,
267
+ )
268
+
269
+ expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
270
+ expect(result.project.dataSource).toBe('supabase')
271
+ expect(result.project.dataSources?.customers).toEqual({
272
+ kind: 'supabase', table: 'customers', url: 'https://abc.supabase.co', key: 'anon-key',
273
+ })
274
+ expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
275
+ // The URL + key flags skip straight to the table question.
276
+ expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
277
+ })
278
+
279
+ it('picks up the primary key and the foreign key from the API doc', async () => {
280
+ stubSupabase(SUPABASE_DEFS)
281
+ const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
282
+ const fs = memoryIo()
283
+ const { project } = await runStudioInit(
284
+ { supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
285
+ )
286
+ const orders = project.entities.find((e) => e.name === 'orders')!
287
+ expect(orders.fields.find((f) => f.field === 'id')?.primaryKey).toBe(true)
288
+ expect(orders.fields.find((f) => f.field === 'customer_id')?.relation?.entity).toBe('customers')
289
+ // A relation means the customers detail page gets an orders tab.
290
+ expect(project.screens.some((s) => s.id === 'customers-detail')).toBe(true)
291
+ })
292
+
293
+ it('imports only the picked tables', async () => {
294
+ stubSupabase(SUPABASE_DEFS)
295
+ const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Shop', '.'])
296
+ const fs = memoryIo()
297
+ const { project } = await runStudioInit(
298
+ { supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
299
+ )
300
+ expect(project.entities.map((e) => e.name)).toEqual(['orders'])
301
+ })
302
+
303
+ it('falls back to typed table names when the API doc is restricted', async () => {
304
+ // 401 on the doc, but each table still introspects from a sample row.
305
+ let calls = 0
306
+ vi.stubGlobal('fetch', async (input: string | URL) => {
307
+ const href = String(input)
308
+ calls++
309
+ if (href.endsWith('/rest/v1/')) return new Response('no', { status: 401 })
310
+ return new Response(JSON.stringify([{ id: 1, name: 'Acme' }]), {
311
+ status: 200, headers: { 'content-type': 'application/json' },
312
+ })
313
+ })
314
+ const prompts = scriptedPrompts(['customers', 'y', '1', '1', 'n', 'Shop', '.'])
315
+ const fs = memoryIo()
316
+ const { project } = await runStudioInit(
317
+ { supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
318
+ )
319
+ expect(calls).toBeGreaterThan(0)
320
+ expect(project.entities.map((e) => e.name)).toEqual(['customers'])
321
+ expect(project.entities[0]!.fields.map((f) => f.field)).toContain('name')
322
+ expect(prompts.asked).toContain('Table names (comma separated):')
323
+ })
324
+
325
+ it('requires both the URL and the key', async () => {
326
+ const prompts = scriptedPrompts(['', ''])
327
+ const fs = memoryIo()
328
+ await expect(runStudioInit({ supabaseUrl: 'https://abc.supabase.co' }, prompts.io, null, fs.io))
329
+ .rejects.toThrow('project URL and the anon key are required')
330
+ })
331
+
332
+ it('fails clearly when no picked table can be read', async () => {
333
+ // Doc lists nothing AND every per-table fallback 404s.
334
+ vi.stubGlobal('fetch', async (input: string | URL) =>
335
+ String(input).endsWith('/rest/v1/')
336
+ ? new Response(JSON.stringify({ definitions: {} }), { status: 200, headers: { 'content-type': 'application/json' } })
337
+ : new Response('missing', { status: 404 }),
338
+ )
339
+ const prompts = scriptedPrompts(['ghosts'])
340
+ const fs = memoryIo()
341
+ await expect(runStudioInit(
342
+ { supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
343
+ )).rejects.toThrow('None of the picked tables could be read')
344
+ })
345
+ })
346
+
347
+ it('applies the chosen theme', async () => {
348
+ const prompts = scriptedPrompts([])
349
+ const fs = memoryIo()
350
+ const result = await runStudioInit({ yes: true, theme: 'material', dark: true }, prompts.io, null, fs.io)
351
+ expect(result.project.theme?.preset).toBe('material')
352
+ expect(result.project.theme?.mode).toBe('dark')
353
+ })
354
+
355
+ it('keeps user-owned files that already exist', async () => {
356
+ const fs = memoryIo()
357
+ await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
358
+ const handlers = [...fs.files.keys()].find((p) => p.endsWith('handlers.ts'))
359
+ if (!handlers) return // no user-owned companion in this project shape
360
+ fs.files.set(handlers, '// my own code')
361
+ await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
362
+ expect(fs.files.get(handlers)).toBe('// my own code')
363
+ })
364
+ })