@svgrid/enterprise 2.3.0 → 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 (73) 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 +11308 -7898
  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/SvGridChartView-DX9HfkBR.css +1 -0
  14. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
  15. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
  16. package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
  17. package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
  18. package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
  19. package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
  20. package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
  21. package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
  22. package/dist/designer/assets/easing-zaypKrr0.js +3 -0
  23. package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
  24. package/dist/designer/assets/index-DVrv9dH6.js +75797 -0
  25. package/dist/designer/assets/index-DjPp1mn7.css +1 -0
  26. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
  27. package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
  28. package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
  29. package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
  30. package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  31. package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
  32. package/dist/designer/assets/src-BRKu53tV.js +2842 -0
  33. package/dist/designer/assets/src-DseB1VRN.css +1 -0
  34. package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
  35. package/dist/designer/index.html +32 -0
  36. package/dist/node/studio.js +16778 -12672
  37. package/package.json +10 -7
  38. package/src/SvGridBoard.svelte +4 -1
  39. package/src/SvGridScheduler.svelte +114 -90
  40. package/src/ai-export-pdf.dom.test.ts +77 -77
  41. package/src/ai-export-xlsx.dom.test.ts +90 -90
  42. package/src/ai-export.dom.test.ts +114 -114
  43. package/src/export-ooxml.ts +4 -0
  44. package/src/export-xls.ts +3 -0
  45. package/src/expressions/evaluate.ts +9 -0
  46. package/src/import.test.ts +1 -1
  47. package/src/import.ts +1 -1
  48. package/src/index.ts +12 -0
  49. package/src/pivot.test.ts +0 -1
  50. package/src/schema-designer.ts +1 -1
  51. package/src/sources/introspect-supabase.ts +2 -2
  52. package/src/studio/emit-project.test.ts +298 -7
  53. package/src/studio/emit-project.ts +484 -30
  54. package/src/studio/emit-schema.ts +20 -10
  55. package/src/studio/index.ts +34 -0
  56. package/src/studio/init-flow.test.ts +364 -0
  57. package/src/studio/init-flow.ts +409 -0
  58. package/src/studio/project.test.ts +0 -1
  59. package/src/studio/project.ts +109 -2
  60. package/src/studio/samples/datasets.test.ts +84 -0
  61. package/src/studio/samples/datasets.ts +340 -0
  62. package/src/studio/samples/live-data.test.ts +1 -1
  63. package/src/studio/samples/samples.test.ts +268 -268
  64. package/src/studio/samples/shared.ts +7 -150
  65. package/src/studio/screen-suites.test.ts +226 -0
  66. package/src/studio/screen-suites.ts +445 -0
  67. package/src/studio/ui-components-surface.test.ts +2 -2
  68. package/src/studio/ui-components.generated.ts +1643 -106
  69. package/src/studio/ui-components.ts +742 -641
  70. package/src/sveltekit/index.ts +1 -0
  71. package/src/sveltekit/sql-source.ts +1 -1
  72. package/src/sveltekit/transport-scope.test.ts +125 -0
  73. package/src/sveltekit/transport.ts +76 -2
@@ -10,7 +10,7 @@
10
10
  * build. It targets the template's conventions: `$lib/EntityScreen.svelte`,
11
11
  * `$lib/schemas`, `$lib/data`, and the `st-*` / `.home` CSS classes.
12
12
  */
13
- import { resolveIdField, titleCase, type EntityField, type EntityFieldType, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
13
+ import { resolveIdField, titleCase, type EntityField, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
14
14
  import type { GeneratedFile } from './scaffold.js'
15
15
  import type { EntityDataSource, RestAdapterConfig, RestSource, ShellConfig, ShellStyle, SqlDialectKind, SqlSource, EntityTriggers, TriggerEvent } from './project.js'
16
16
  import { sanitizeClassName, compileTriggerSteps, TRIGGER_EVENTS } from './project.js'
@@ -362,17 +362,19 @@ const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', {
362
362
  * the route imports the shared access policy and rejects unauthorized writes -
363
363
  * server-enforced, so a tampered client can't bypass it. When audit is on, every
364
364
  * successful write is recorded. */
365
- function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers; dbSchema?: string } = {}): GeneratedFile {
365
+ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers; dbSchema?: string; tenantField?: string } = {}): GeneratedFile {
366
366
  const n = namesFor(schema)
367
367
  const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
368
368
  const driver = SQL_DRIVERS[key]
369
369
  const dialectLiteral = sqlDialectExpr(dialect)
370
370
  const accessImport = feat.access ? `\nimport { authorizeAction, getServerRole } from '$lib/access'` : ''
371
371
  const auditImport = feat.audit ? `\nimport { recordAudit } from '$lib/audit'` : ''
372
+ const tenantImport = feat.tenantField ? `\nimport { requireTenant } from '$lib/server/tenant'` : ''
372
373
  // Every connected route validates writes against the schema server-side, and
373
374
  // (when enabled) authorizes them by role + records an audit entry.
374
375
  const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
375
376
  if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals). Reads\n // are allowed only if the role can open one of this entity's own screens.\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ${JSON.stringify(feat.screenIds ?? [])})`)
377
+ if (feat.tenantField) opts.push(`// Multi-tenancy, enforced server-side: reads are filtered to the caller's tenant,\n // creates are stamped with it, and update/delete re-read the target under the\n // scope first - so a guessed id cannot reach another tenant's row.\n scope: ({ event }) => ({ field: ${JSON.stringify(feat.tenantField)}, value: requireTenant(event) })`)
376
378
  if (feat.audit) opts.push(`// Record every successful write to the audit trail.\n audit: (e) => recordAudit({ entity: ${JSON.stringify(schema.name)}, action: e.action, recordId: e.id, values: e.values as Record<string, unknown> | undefined, actor: String(e.event.locals?.role ?? e.event.locals?.user ?? 'system') })`)
377
379
  // Server-enforced business rules: the entity's triggers compiled to lifecycle hooks.
378
380
  const hooks = triggerHooksOpt(feat.triggers)
@@ -384,7 +386,7 @@ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectK
384
386
  contents: `${driver.imports}
385
387
  import { env } from '$env/dynamic/private'
386
388
  import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'
387
- import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}
389
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}${tenantImport}
388
390
 
389
391
  ${driver.setup}
390
392
 
@@ -737,7 +739,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
737
739
 
738
740
  // i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
739
741
  const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
740
- const anchor = `<a class="sv-app__link" class:is-active={$page.url.pathname === item.href} href={item.href}>${navLabel}</a>`
742
+ const anchor = `<a class="sv-app__link" class:is-active={page.url.pathname === item.href} href={item.href}>${navLabel}</a>`
741
743
  // When RBAC is on, hide nav links the current role can't open.
742
744
  const linkGate = opts.access
743
745
  ? `{#if !item.id || canScreen($currentRole, item.id)}${anchor}{/if}`
@@ -968,14 +970,14 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
968
970
  contents: `<script lang="ts">
969
971
  import '../app.css'
970
972
  import '../custom.css'
971
- import { page } from '$app/stores'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}${opts.auth ? `\n import type { LayoutData } from './$types'` : ''}${opts.supabaseAuth ? `\n import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'\n import { supabaseClient } from '$lib/connections'` : ''}
973
+ import { page } from '$app/state'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}${opts.auth ? `\n import type { LayoutData } from './$types'` : ''}${opts.supabaseAuth ? `\n import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'\n import { supabaseClient } from '$lib/connections'` : ''}
972
974
 
973
975
  let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
974
976
  const nav = ${JSON.stringify(links)}
975
977
  const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
976
978
  let navOpen = $state(false)${opts.auth && opts.access ? `\n // Seed the client role store from the signed-in session (server-resolved).\n $effect(() => { if (data?.role) currentRole.set(data.role as never) })` : ''}
977
979
  // Close the mobile drawer whenever the route changes.
978
- $effect(() => { void $page.url.pathname; navOpen = false })${hasSwitch ? `
980
+ $effect(() => { void page.url.pathname; navOpen = false })${hasSwitch ? `
979
981
  // Light/dark switcher: defaults to the mode picked in Studio, then honours the
980
982
  // visitor's saved choice. Applies via [data-theme] on <html> (see the token
981
983
  // sets in <svelte:head>), and persists per browser.
@@ -1014,11 +1016,11 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
1014
1016
  return () => window.removeEventListener('keydown', onKey)
1015
1017
  })
1016
1018
  // Close both menus on route change.
1017
- $effect(() => { void $page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
1019
+ $effect(() => { void page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
1018
1020
  </script>
1019
1021
  ${themeHead}
1020
1022
 
1021
- ${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes($page.url.pathname)}
1023
+ ${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes(page.url.pathname)}
1022
1024
  {@render children()}
1023
1025
  {:else}
1024
1026
  ${body}
@@ -1137,7 +1139,7 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
1137
1139
  /** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
1138
1140
  export function emitEntityModules(
1139
1141
  schemas: EntitySchema[],
1140
- opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers>; supabaseConn?: { url?: string; key?: string }; supabaseAuth?: boolean } = {},
1142
+ opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers>; supabaseConn?: { url?: string; key?: string }; supabaseAuth?: boolean; tenantField?: string; tenantScoped?: (entity: string) => boolean } = {},
1141
1143
  ): { files: GeneratedFile[]; prepared: EntitySchema[] } {
1142
1144
  const { entries, seed } = prepareEntities(schemas)
1143
1145
  const { file: data, needs } = dataModule(entries, seed, opts.sources, opts.supabaseConn)
@@ -1148,7 +1150,15 @@ export function emitEntityModules(
1148
1150
  needs.supabaseUrl = opts.supabaseConn.url; needs.supabaseKey = opts.supabaseConn.key
1149
1151
  }
1150
1152
  if (needs.supabase || opts.supabaseAuth) files.push(connectionsModule(needs))
1151
- for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled, screenIds: opts.screensByEntity?.get(r.schema.name) ?? [], triggers: opts.triggers?.[r.schema.name], dbSchema: r.dbSchema }))
1153
+ for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, {
1154
+ access: opts.accessEnabled,
1155
+ audit: opts.auditEnabled,
1156
+ screenIds: opts.screensByEntity?.get(r.schema.name) ?? [],
1157
+ triggers: opts.triggers?.[r.schema.name],
1158
+ dbSchema: r.dbSchema,
1159
+ // A shared (reference-data) entity stays global - no scope on its route.
1160
+ tenantField: opts.tenantField && opts.tenantScoped?.(r.schema.name) !== false ? opts.tenantField : undefined,
1161
+ }))
1152
1162
  return { files, prepared: entries.map((e) => e.schema) }
1153
1163
  }
1154
1164
 
@@ -44,6 +44,33 @@ export { emitStudioProject, emitStudioAppBundle, emitStudioFragment, runtimeDeps
44
44
  export { sampleApps, getSampleApp, type SampleApp } from './samples/index.js'
45
45
  export { starterProject } from './samples/starter.js'
46
46
  export { liveDataSamples, getLiveDataSample } from './samples/live-data.js'
47
+ export { starterDatasets, getStarterDataset, type StarterDataset } from './samples/datasets.js'
48
+ export {
49
+ runStudioInit,
50
+ parseSelection,
51
+ type PromptIO,
52
+ type DbGateway,
53
+ type FetchText,
54
+ type InitFlags,
55
+ type InitResult,
56
+ } from './init-flow.js'
57
+ export {
58
+ crudSuiteScreens,
59
+ addCrudSuite,
60
+ crudAppFromSchemas,
61
+ listScreen,
62
+ formScreen,
63
+ detailScreen,
64
+ dashScreen,
65
+ statusPills,
66
+ gridConfig,
67
+ type CrudScreenKind,
68
+ type CrudEditingMode,
69
+ type CrudSuiteOptions,
70
+ type CrudAppOptions,
71
+ type GridOpts,
72
+ type Tile,
73
+ } from './screen-suites.js'
47
74
  export {
48
75
  sanitizeStudioProject,
49
76
  buildStudioBugReport,
@@ -134,6 +161,13 @@ export {
134
161
  type TriggerStep,
135
162
  type TriggerEvent,
136
163
  type EntityTriggers,
164
+ setJob,
165
+ type ScheduledJob,
166
+ type ScheduledJobKind,
167
+ setTenancy,
168
+ tenantField,
169
+ isTenantScoped,
170
+ type TenancyConfig,
137
171
  clickSlot,
138
172
  ssrScreenShape,
139
173
  ssrEligible,
@@ -0,0 +1,364 @@
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
+ })