@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
@@ -0,0 +1,409 @@
1
+ /**
2
+ * runStudioInit - the guided "build a CRUD app" flow behind `svgrid-studio init`.
3
+ *
4
+ * Asks a handful of questions (where the data lives, which tables, which pages,
5
+ * what it looks like), then builds a project with `crudAppFromSchemas` - the same
6
+ * generator the designer's "New app" wizard uses - and writes a runnable app plus
7
+ * its `studio.config.json`.
8
+ *
9
+ * Prompts, the filesystem, and the database are all injected, so the orchestration
10
+ * is pure and unit-testable and this file stays node-free (no process / fs / driver
11
+ * imports). The CLI bin supplies readline, node:fs, and the DB drivers.
12
+ */
13
+ import type { EntitySchema } from '../schema.js'
14
+ import type { StudioIO } from './cli.js'
15
+ import { crudAppFromSchemas, type CrudEditingMode, type CrudScreenKind, type CrudSuiteOptions } from './screen-suites.js'
16
+ import { starterDatasets, getStarterDataset } from './samples/datasets.js'
17
+ import { introspectDatabase, listDatabaseTables, countTableRows, type DbExecute, type SqlDialectName } from './introspect-db.js'
18
+ import { introspectOpenApi } from './introspect-openapi.js'
19
+ import { introspectJson } from './introspect.js'
20
+ import { introspectSupabaseTable, listSupabaseTables } from '../sources/introspect-supabase.js'
21
+ import { buildConnectionString, isFileDialect, type SqlConnectionParts } from './db-connect-string.js'
22
+ import { emitStudioAppBundle } from './emit-project.js'
23
+ import { mergeManaged, skipUserOwned } from './scaffold.js'
24
+ import { getStudioTheme, studioThemes } from './themes.js'
25
+ import { serializeProject, type DataSourceKind, type EntityDataSource, type ProjectTheme, type StudioProject } from './project.js'
26
+
27
+ /** Terminal I/O, injected so the flow can be scripted in tests. */
28
+ export type PromptIO = {
29
+ /** Ask a question; `def` is used when the user just hits Enter. */
30
+ ask: (question: string, def?: string) => Promise<string>
31
+ /** Print a line (progress, headings, results). */
32
+ say: (line: string) => void
33
+ }
34
+
35
+ /** Live-database access, injected by the bin (drivers are node-only). */
36
+ export type DbGateway = {
37
+ /** Make sure the dialect's driver is installed; report why not if it isn't. */
38
+ ensureDriver: (dialect: SqlDialectName) => Promise<{ ok: boolean; message?: string }>
39
+ connect: (dialect: SqlDialectName, url: string) => Promise<DbExecute>
40
+ }
41
+
42
+ /** Read a REST endpoint or an OpenAPI document (injected: the bin uses fetch/fs). */
43
+ export type FetchText = (source: string) => Promise<string>
44
+
45
+ export type InitFlags = {
46
+ /** Where to write the app. Default `.`. */
47
+ out?: string
48
+ /** Skip the source question and go straight to a live database. */
49
+ db?: SqlDialectName
50
+ /** Connection string for `db`. */
51
+ url?: string
52
+ /** Supabase project URL - skips the source question and both prompts. */
53
+ supabaseUrl?: string
54
+ /** Supabase anon key. */
55
+ supabaseKey?: string
56
+ /** Pick a starter dataset by id, skipping the source question. */
57
+ dataset?: string
58
+ /** Theme preset id. */
59
+ theme?: string
60
+ dark?: boolean
61
+ /** App title (skips the question). */
62
+ title?: string
63
+ /** Take every default; ask nothing that has one. */
64
+ yes?: boolean
65
+ }
66
+
67
+ export type InitResult = {
68
+ project: StudioProject
69
+ /** Paths written, in order. */
70
+ written: string[]
71
+ /** What to tell the user to do next. */
72
+ nextSteps: string[]
73
+ }
74
+
75
+ const SOURCES = [
76
+ { key: 'sample', label: 'Sample data', hint: 'a seeded pair of related tables - see a working app immediately' },
77
+ { key: 'db', label: 'An existing database', hint: 'Postgres, MySQL, SQL Server or SQLite - reads your tables' },
78
+ { key: 'supabase', label: 'Supabase', hint: 'project URL + anon key, read over the REST API - no driver needed' },
79
+ { key: 'pglite', label: 'Local Postgres in the browser (PGlite)', hint: 'real SQL, persists, no server to set up' },
80
+ { key: 'rest', label: 'A REST API or OpenAPI spec', hint: 'infers the tables from the endpoint' },
81
+ ] as const
82
+ type SourceKey = (typeof SOURCES)[number]['key']
83
+
84
+ const DIALECTS: SqlDialectName[] = ['postgres', 'supabase', 'mysql', 'mssql', 'sqlite']
85
+
86
+ /** Ask a numbered-list question; returns the chosen index. */
87
+ async function pick(prompts: PromptIO, heading: string, items: { label: string; hint?: string }[], def = 1): Promise<number> {
88
+ prompts.say('')
89
+ prompts.say(heading)
90
+ items.forEach((item, i) => prompts.say(` ${i + 1}. ${item.label}${item.hint ? ` - ${item.hint}` : ''}`))
91
+ const answer = (await prompts.ask('Pick a number:', String(def))).trim()
92
+ const n = Number(answer)
93
+ return Number.isFinite(n) && n >= 1 && n <= items.length ? n - 1 : def - 1
94
+ }
95
+
96
+ const isYes = (answer: string, def: boolean): boolean => {
97
+ const a = answer.trim().toLowerCase()
98
+ if (!a) return def
99
+ return a === 'y' || a === 'yes'
100
+ }
101
+
102
+ /** Parse "all", "none", a comma list of names, or a list of 1-based numbers. */
103
+ export function parseSelection(answer: string, items: string[]): string[] {
104
+ const raw = answer.trim()
105
+ if (!raw || raw.toLowerCase() === 'all' || raw === '*') return items
106
+ if (raw.toLowerCase() === 'none') return []
107
+ const picked: string[] = []
108
+ for (const part of raw.split(',').map((p) => p.trim()).filter(Boolean)) {
109
+ const n = Number(part)
110
+ if (Number.isFinite(n) && n >= 1 && n <= items.length) {
111
+ const item = items[n - 1]!
112
+ if (!picked.includes(item)) picked.push(item)
113
+ continue
114
+ }
115
+ const match = items.find((i) => i.toLowerCase() === part.toLowerCase())
116
+ if (match && !picked.includes(match)) picked.push(match)
117
+ }
118
+ return picked
119
+ }
120
+
121
+ /** The rows of a REST response: a bare array, or the usual wrapper key. */
122
+ function unwrapRows(body: unknown): Record<string, unknown>[] {
123
+ if (Array.isArray(body)) return body as Record<string, unknown>[]
124
+ if (body && typeof body === 'object') {
125
+ for (const key of ['data', 'items', 'results', 'rows', 'records']) {
126
+ const v = (body as Record<string, unknown>)[key]
127
+ if (Array.isArray(v)) return v as Record<string, unknown>[]
128
+ }
129
+ return [body as Record<string, unknown>]
130
+ }
131
+ return []
132
+ }
133
+
134
+ type Gathered = {
135
+ schemas: EntitySchema[]
136
+ sources: Record<string, EntityDataSource>
137
+ seed: Record<string, Record<string, unknown>[]>
138
+ kind: DataSourceKind
139
+ title?: string
140
+ }
141
+
142
+ /** Step 1-2: where does the data come from, and what tables does it have? */
143
+ async function gatherData(flags: InitFlags, prompts: PromptIO, db: DbGateway | null, fetchText: FetchText | null): Promise<Gathered> {
144
+ let source: SourceKey = 'sample'
145
+ if (flags.db) source = 'db'
146
+ else if (flags.supabaseUrl) source = 'supabase'
147
+ else if (flags.dataset) source = 'sample'
148
+ else if (!flags.yes) source = SOURCES[await pick(prompts, 'Where should the app get its data?', [...SOURCES])]!.key
149
+
150
+ if (source === 'db') return gatherFromDatabase(flags, prompts, db)
151
+ if (source === 'supabase') return gatherFromSupabase(flags, prompts)
152
+ if (source === 'rest') return gatherFromRest(prompts, fetchText)
153
+
154
+ // Sample data, stored in memory or in PGlite.
155
+ let datasetId = flags.dataset ?? starterDatasets[0]!.id
156
+ if (!flags.dataset && !flags.yes) {
157
+ const i = await pick(prompts, 'Pick a starter dataset:', starterDatasets.map((d) => ({ label: d.name, hint: d.description })))
158
+ datasetId = starterDatasets[i]!.id
159
+ }
160
+ const dataset = getStarterDataset(datasetId)
161
+ if (!dataset) throw new Error(`Unknown dataset "${datasetId}". Try: ${starterDatasets.map((d) => d.id).join(', ')}`)
162
+ const { entities, seed } = dataset.build()
163
+ const kind: DataSourceKind = source === 'pglite' ? 'pglite' : 'memory'
164
+ const sources: Record<string, EntityDataSource> = {}
165
+ for (const e of entities) sources[e.name] = kind === 'pglite' ? { kind: 'pglite', table: e.name } : { kind: 'memory' }
166
+ prompts.say(` Using ${dataset.name} (${entities.length} tables).`)
167
+ return { schemas: entities, sources, seed, kind, title: dataset.name }
168
+ }
169
+
170
+ async function gatherFromDatabase(flags: InitFlags, prompts: PromptIO, db: DbGateway | null): Promise<Gathered> {
171
+ if (!db) throw new Error('Connecting to a database is not available here.')
172
+ let dialect = flags.db
173
+ if (!dialect) {
174
+ const i = await pick(prompts, 'Which database?', DIALECTS.map((d) => ({ label: d })))
175
+ dialect = DIALECTS[i]!
176
+ }
177
+ let url = flags.url ?? ''
178
+ if (!url) {
179
+ if (isFileDialect(dialect)) {
180
+ url = (await prompts.ask('Database file:', './data.db')).trim()
181
+ } else {
182
+ const answer = (await prompts.ask('Connection string (blank to enter host/port instead):', '')).trim()
183
+ url = answer || buildConnectionString(dialect, await askConnectionParts(prompts))
184
+ }
185
+ }
186
+ if (!url) throw new Error('A connection string is required.')
187
+
188
+ const driver = await db.ensureDriver(dialect)
189
+ if (!driver.ok) throw new Error(driver.message ?? `Could not install the ${dialect} driver.`)
190
+
191
+ prompts.say(' Connecting...')
192
+ const execute = await db.connect(dialect, url)
193
+ const tables = await listDatabaseTables(dialect, execute)
194
+ if (tables.length === 0) throw new Error('Connected, but the database has no tables.')
195
+
196
+ const counts = await countTableRows(dialect, execute, tables)
197
+ prompts.say('')
198
+ prompts.say(`Found ${tables.length} ${tables.length === 1 ? 'table' : 'tables'}:`)
199
+ counts.forEach((t, i) => prompts.say(` ${i + 1}. ${t.name}${t.rows == null ? '' : ` (${t.rows} rows)`}`))
200
+ const answer = flags.yes ? 'all' : await prompts.ask('Which tables? (all, or a comma list of names/numbers)', 'all')
201
+ const picked = parseSelection(answer, tables)
202
+ if (picked.length === 0) throw new Error('No tables picked - nothing to build.')
203
+
204
+ const schemas: EntitySchema[] = []
205
+ const sources: Record<string, EntityDataSource> = {}
206
+ for (const table of picked) {
207
+ try {
208
+ const schema = await introspectDatabase({ dialect, table, execute })
209
+ schemas.push(schema)
210
+ sources[schema.name] = { kind: 'sql', table, dialect }
211
+ } catch (e) {
212
+ // One unreadable table must never sink the run.
213
+ prompts.say(` ! Skipped ${table}: ${e instanceof Error ? e.message : String(e)}`)
214
+ }
215
+ }
216
+ if (schemas.length === 0) throw new Error('None of the picked tables could be read.')
217
+ prompts.say(` Read ${schemas.length} ${schemas.length === 1 ? 'table' : 'tables'}.`)
218
+ return { schemas, sources, seed: {}, kind: 'sql' }
219
+ }
220
+
221
+ /**
222
+ * Supabase reads over its own REST API, so this needs no driver and no
223
+ * DbGateway - just the project URL and the anon key.
224
+ */
225
+ async function gatherFromSupabase(flags: InitFlags, prompts: PromptIO): Promise<Gathered> {
226
+ const url = (flags.supabaseUrl ?? (await prompts.ask('Project URL:', ''))).trim()
227
+ const key = (flags.supabaseKey ?? (await prompts.ask('Anon key:', ''))).trim()
228
+ if (!url || !key) throw new Error('Both the project URL and the anon key are required.')
229
+
230
+ prompts.say(' Reading the API...')
231
+ const tables = await listSupabaseTables(url, key)
232
+ let picked: string[]
233
+ if (tables.length) {
234
+ prompts.say('')
235
+ prompts.say(`Found ${tables.length} ${tables.length === 1 ? 'table' : 'tables'}:`)
236
+ tables.forEach((t, i) => prompts.say(` ${i + 1}. ${t}`))
237
+ const answer = flags.yes ? 'all' : await prompts.ask('Which tables? (all, or a comma list of names/numbers)', 'all')
238
+ picked = parseSelection(answer, tables)
239
+ } else {
240
+ // The API doc is often CORS-blocked or restricted; naming the tables by hand
241
+ // still works because each one is introspected individually.
242
+ prompts.say(" Couldn't list the tables (the API doc may be restricted for this key).")
243
+ const typed = await prompts.ask('Table names (comma separated):', '')
244
+ picked = typed.split(',').map((t) => t.trim()).filter(Boolean)
245
+ }
246
+ if (picked.length === 0) throw new Error('No tables picked - nothing to build.')
247
+
248
+ const schemas: EntitySchema[] = []
249
+ const sources: Record<string, EntityDataSource> = {}
250
+ for (const table of picked) {
251
+ const schema = await introspectSupabaseTable({ url, key, table })
252
+ if (!schema?.fields.length) {
253
+ prompts.say(` ! Skipped ${table}: no columns could be read.`)
254
+ continue
255
+ }
256
+ schemas.push(schema as EntitySchema)
257
+ sources[schema.name] = { kind: 'supabase', table, url, key }
258
+ }
259
+ if (schemas.length === 0) throw new Error('None of the picked tables could be read.')
260
+ prompts.say(` Read ${schemas.length} ${schemas.length === 1 ? 'table' : 'tables'}.`)
261
+ return { schemas, sources, seed: {}, kind: 'supabase' }
262
+ }
263
+
264
+ async function askConnectionParts(prompts: PromptIO): Promise<SqlConnectionParts> {
265
+ return {
266
+ host: (await prompts.ask('Host:', 'localhost')).trim(),
267
+ port: (await prompts.ask('Port:', '')).trim(),
268
+ database: (await prompts.ask('Database:', 'app')).trim(),
269
+ user: (await prompts.ask('User:', 'postgres')).trim(),
270
+ password: (await prompts.ask('Password:', '')).trim(),
271
+ }
272
+ }
273
+
274
+ async function gatherFromRest(prompts: PromptIO, fetchText: FetchText | null): Promise<Gathered> {
275
+ if (!fetchText) throw new Error('Reading a REST endpoint is not available here.')
276
+ const src = (await prompts.ask('OpenAPI spec (file or URL), or a REST endpoint returning rows:', '')).trim()
277
+ if (!src) throw new Error('A spec path/URL or endpoint is required.')
278
+ const text = await fetchText(src)
279
+
280
+ // An OpenAPI document describes every resource; a plain endpoint gives one.
281
+ if (/"(openapi|swagger|paths)"\s*:/.test(text)) {
282
+ const { entities, sources, warnings } = introspectOpenApi(text)
283
+ for (const w of warnings) prompts.say(` ! ${w}`)
284
+ if (entities.length === 0) throw new Error('The spec described no collection resources.')
285
+ prompts.say(` Read ${entities.length} REST ${entities.length === 1 ? 'resource' : 'resources'}.`)
286
+ return { schemas: entities, sources, seed: {}, kind: 'rest' }
287
+ }
288
+
289
+ const rows = unwrapRows(JSON.parse(text) as unknown)
290
+ if (rows.length === 0) throw new Error('The endpoint returned no rows to read a shape from.')
291
+ const url = new URL(src)
292
+ const path = url.pathname.replace(/^\/+|\/+$/g, '')
293
+ const suggested = path.split('/').filter(Boolean).pop() || 'items'
294
+ const name = ((await prompts.ask('Table name:', suggested)).trim() || suggested).replace(/\W+/g, '_')
295
+ const schema = introspectJson(name, rows)
296
+ prompts.say(` Read ${schema.fields.length} fields from ${rows.length} rows.`)
297
+ return {
298
+ schemas: [schema],
299
+ sources: { [name]: { kind: 'rest', baseUrl: url.origin, path, method: 'GET', params: [] } },
300
+ seed: {},
301
+ kind: 'rest',
302
+ }
303
+ }
304
+
305
+ /** Step 3: which pages does each table get, and how are rows edited? */
306
+ async function askScreens(schemas: EntitySchema[], flags: InitFlags, prompts: PromptIO): Promise<Record<string, CrudSuiteOptions>> {
307
+ const perEntity: Record<string, CrudSuiteOptions> = {}
308
+ let editing: CrudEditingMode = 'form'
309
+ let full = true
310
+
311
+ if (!flags.yes) {
312
+ full = isYes(await prompts.ask('\nGenerate a list, an edit form and a record page for each table? (Y/n)', 'y'), true)
313
+ const modes: { label: string; value: CrudEditingMode }[] = [
314
+ { label: 'A popup form', value: 'form' },
315
+ { label: 'In the grid (like a spreadsheet)', value: 'inline' },
316
+ { label: 'On the record page', value: 'detail' },
317
+ ]
318
+ editing = modes[await pick(prompts, 'How should rows be edited?', modes)]!.value
319
+ }
320
+
321
+ for (const schema of schemas) {
322
+ const hasChildren = schemas.some((e) => e.fields.some((f) => f.type === 'relation' && f.relation?.entity === schema.name))
323
+ const screens: CrudScreenKind[] = full
324
+ ? hasChildren || editing === 'detail' ? ['list', 'form', 'detail'] : ['list', 'form']
325
+ : ['list']
326
+ perEntity[schema.name] = { screens, editing }
327
+ }
328
+ return perEntity
329
+ }
330
+
331
+ /** Step 4: the app's look. */
332
+ async function askTheme(flags: InitFlags, prompts: PromptIO): Promise<ProjectTheme | undefined> {
333
+ let preset = flags.theme?.trim().toLowerCase()
334
+ if (preset && !getStudioTheme(preset)) {
335
+ prompts.say(` ! Unknown theme "${preset}" - using tailwind.`)
336
+ preset = undefined
337
+ }
338
+ if (!preset && !flags.yes) {
339
+ const i = await pick(prompts, 'Theme:', studioThemes.map((t) => ({ label: t.name, hint: t.id })))
340
+ preset = studioThemes[i]?.id
341
+ }
342
+ preset ??= 'tailwind'
343
+ const dark = flags.dark ?? (flags.yes ? false : isYes(await prompts.ask('Dark mode? (y/N)', 'n'), false))
344
+ return { preset, mode: dark ? 'dark' : 'light' }
345
+ }
346
+
347
+ /** Merge-write the generated files, preserving edits outside managed regions. */
348
+ async function writeAll(files: { path: string; contents: string; userOwned?: boolean }[], outDir: string, io: StudioIO): Promise<string[]> {
349
+ const written: string[] = []
350
+ const prefix = !outDir || outDir === '.' ? '' : outDir.replace(/[\\/]+$/, '') + '/'
351
+ for (const file of files) {
352
+ const path = prefix + file.path
353
+ const existing = await io.readFile(path)
354
+ if (skipUserOwned(file, existing != null)) continue
355
+ await io.writeFile(path, mergeManaged(existing, file.contents))
356
+ written.push(path)
357
+ }
358
+ return written
359
+ }
360
+
361
+ /**
362
+ * The whole guided flow: ask, build, write. Returns the project and what was
363
+ * written so the caller can print next steps (or assert them in a test).
364
+ */
365
+ export async function runStudioInit(
366
+ flags: InitFlags,
367
+ prompts: PromptIO,
368
+ db: DbGateway | null,
369
+ io: StudioIO,
370
+ fetchText: FetchText | null = null,
371
+ ): Promise<InitResult> {
372
+ prompts.say('SvGrid Studio - let\'s build your app.')
373
+
374
+ const data = await gatherData(flags, prompts, db, fetchText)
375
+ const perEntity = await askScreens(data.schemas, flags, prompts)
376
+ const theme = await askTheme(flags, prompts)
377
+
378
+ const defaultTitle = flags.title ?? data.title ?? 'My app'
379
+ const title = flags.yes || flags.title ? defaultTitle : (await prompts.ask('\nApp name:', defaultTitle)).trim() || defaultTitle
380
+ const outDir = flags.out ?? (flags.yes ? '.' : (await prompts.ask('Write it where?', '.')).trim() || '.')
381
+
382
+ const project = crudAppFromSchemas(data.schemas, {
383
+ title,
384
+ dataSource: data.kind,
385
+ sources: data.sources,
386
+ seed: data.seed,
387
+ perEntity,
388
+ overviewDashboard: true,
389
+ ...(theme ? { theme } : {}),
390
+ })
391
+
392
+ const files = emitStudioAppBundle(project)
393
+ const written = await writeAll(files, outDir, io)
394
+ // The design itself, so `svgrid-studio designer` reopens exactly this app.
395
+ const configPath = (!outDir || outDir === '.' ? '' : outDir.replace(/[\\/]+$/, '') + '/') + 'studio.config.json'
396
+ await io.writeFile(configPath, serializeProject(project))
397
+ written.push(configPath)
398
+
399
+ prompts.say('')
400
+ prompts.say(`Built "${title}": ${data.schemas.length} ${data.schemas.length === 1 ? 'table' : 'tables'}, ${project.screens.length} screens, ${written.length} files.`)
401
+
402
+ const nextSteps = [
403
+ ...(outDir && outDir !== '.' ? [`cd ${outDir}`] : []),
404
+ 'npm install',
405
+ 'npm run dev',
406
+ 'svgrid-studio designer # keep editing it visually',
407
+ ]
408
+ return { project, written, nextSteps }
409
+ }
@@ -39,7 +39,6 @@ import {
39
39
  blockPalette,
40
40
  createProject,
41
41
  defaultBlockConfig,
42
- defaultScreenFor,
43
42
  gridColumns,
44
43
  moveBlock,
45
44
  removeBlock,
@@ -203,7 +203,20 @@ export type SchedulerViewConfig = {
203
203
  export type TreeDataConfig = { parentField: string; labelField: string }
204
204
  /** Which export affordances the grid toolbar shows. All go through the grid's built-in
205
205
  * export API (dependency-free). Empty / all-false = no toolbar. */
206
- export type GridExportConfig = { csv?: boolean; json?: boolean; copy?: boolean }
206
+ /** Export buttons on a grid's toolbar.
207
+ *
208
+ * `csv` / `json` / `copy` use the free grid API. `xlsx` / `pdf` / `print` go
209
+ * through `@svgrid/enterprise` (real OOXML, pdfmake, paginated print) and pull
210
+ * in their optional peer deps - jszip for xlsx, pdfmake for pdf - which the
211
+ * generated package.json declares only when the button is switched on. */
212
+ export type GridExportConfig = {
213
+ csv?: boolean
214
+ json?: boolean
215
+ copy?: boolean
216
+ xlsx?: boolean
217
+ pdf?: boolean
218
+ print?: boolean
219
+ }
207
220
  /** Which filtering surfaces the grid shows (when `filterable`). `global` = the search-all
208
221
  * box, `row` = a filter input under each header, `menu` = the column header filter menu. */
209
222
  export type GridFilterUi = { global?: boolean; row?: boolean; menu?: boolean }
@@ -817,6 +830,68 @@ export type StudioProject = {
817
830
  /** Server-side business-rule triggers, keyed by entity name. Enforced on the
818
831
  * SQL route (compiled into createKitHandlers `hooks`). */
819
832
  triggers?: Record<string, EntityTriggers>
833
+ /** Scheduled background jobs: a guarded `/api/cron` endpoint plus the schedule
834
+ * config for the deploy target. Empty / omitted emits nothing. */
835
+ jobs?: ScheduledJob[]
836
+ /** Multi-tenancy: scope every row to the signed-in user's tenant. */
837
+ tenancy?: TenancyConfig
838
+ }
839
+
840
+ /**
841
+ * Multi-tenancy for the generated app: one database, one deployment, rows
842
+ * partitioned by a tenant column.
843
+ *
844
+ * Enforced on the SERVER, in the API route's `scope` option - reads are
845
+ * filtered, creates are stamped, and updates/deletes re-read the target under
846
+ * the scope first. A client that skips the UI still cannot reach another
847
+ * tenant's rows.
848
+ *
849
+ * Requires the [auth starter](./auth.md) (the tenant comes from the session)
850
+ * and the typed data layer (the column has to exist in the schema). Without
851
+ * both it degrades to off rather than emitting a half-enforced scope.
852
+ */
853
+ export type TenancyConfig = {
854
+ enabled: boolean
855
+ /** Column holding the tenant key on every scoped entity. Default `tenantId`. */
856
+ field?: string
857
+ /** Entities to leave GLOBAL (shared across tenants) - reference/lookup tables
858
+ * like currencies or countries that every tenant reads. */
859
+ sharedEntities?: string[]
860
+ }
861
+
862
+ /** What a scheduled job does when it fires.
863
+ * - `email`: send a summary of an entity (row count + the newest rows).
864
+ * - `code`: run a body you write, with `db` / `sendEmail` in scope. */
865
+ export type ScheduledJobKind = 'email' | 'code'
866
+
867
+ /**
868
+ * One scheduled background job.
869
+ *
870
+ * Jobs run on the SERVER via a generated `/api/cron` route, not in a browser
871
+ * tab - unlike the client-side `createScheduler` in `@svgrid/enterprise`, which
872
+ * only fires while the app is open. The route is secret-guarded and invoked by
873
+ * the platform's scheduler (Vercel Cron, a GitHub Actions schedule, or your own
874
+ * crontab hitting the URL).
875
+ */
876
+ export type ScheduledJob = {
877
+ /** Stable id - the `?job=` selector and the handler name. */
878
+ id: string
879
+ /** Human label used in comments + the DEPLOY.md table. */
880
+ name: string
881
+ /** Standard 5-field cron expression, interpreted in UTC. */
882
+ cron: string
883
+ /** Off by default is surprising for something you explicitly added, so a job
884
+ * runs unless `enabled` is explicitly false. */
885
+ enabled?: boolean
886
+ kind: ScheduledJobKind
887
+ /** `email`: the entity to summarize. */
888
+ entity?: string
889
+ /** `email`: recipient address. */
890
+ to?: string
891
+ /** `email`: subject line. Defaults to the job name. */
892
+ subject?: string
893
+ /** `code`: the handler body (TypeScript). */
894
+ code?: string
820
895
  }
821
896
 
822
897
  /** The render mode for a screen. `'ssr'` emits idiomatic SvelteKit (`+page.server.ts`
@@ -884,6 +959,32 @@ export function setTrigger(project: StudioProject, entity: string, event: Trigge
884
959
  return { ...project, triggers: Object.keys(triggers).length ? triggers : undefined }
885
960
  }
886
961
 
962
+ /** Turn multi-tenancy on/off and configure the scoping column. */
963
+ export function setTenancy(project: StudioProject, tenancy: TenancyConfig | null): StudioProject {
964
+ if (!tenancy || !tenancy.enabled) {
965
+ const { tenancy: _drop, ...rest } = project
966
+ return rest
967
+ }
968
+ return { ...project, tenancy }
969
+ }
970
+
971
+ /** The tenant column name, defaulted. */
972
+ export const tenantField = (project: StudioProject): string => project.tenancy?.field || 'tenantId'
973
+
974
+ /** True when `entity` is scoped to a tenant (i.e. tenancy is on and it is not
975
+ * listed as shared). Shared entities stay global reference data. */
976
+ export function isTenantScoped(project: StudioProject, entity: string): boolean {
977
+ if (project.tenancy?.enabled !== true) return false
978
+ return !(project.tenancy.sharedEntities ?? []).includes(entity)
979
+ }
980
+
981
+ /** Add or replace a scheduled job (matched by `id`). Pass `null` to remove one. */
982
+ export function setJob(project: StudioProject, id: string, job: Omit<ScheduledJob, 'id'> | null): StudioProject {
983
+ const rest = (project.jobs ?? []).filter((j) => j.id !== id)
984
+ const next = job ? [...rest, { ...job, id }] : rest
985
+ return { ...project, jobs: next.length ? next : undefined }
986
+ }
987
+
887
988
  /** Where the generated app deploys. Drives the emitted SvelteKit adapter + config. */
888
989
  export type DeployTarget = 'auto' | 'vercel' | 'netlify' | 'cloudflare' | 'node'
889
990
 
@@ -1378,7 +1479,13 @@ export function removeScreen(project: StudioProject, screenId: string): StudioPr
1378
1479
  return { ...project, screens: project.screens.filter((s) => s.id !== screenId) }
1379
1480
  }
1380
1481
 
1381
- export function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className'>>): StudioProject {
1482
+ export function updateScreen(
1483
+ project: StudioProject,
1484
+ screenId: string,
1485
+ // `renderMode` is patchable here so SSR output is reachable without the
1486
+ // designer - the CLI and the MCP tools drive the model through this function.
1487
+ patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className' | 'renderMode'>>,
1488
+ ): StudioProject {
1382
1489
  return mapScreen(project, screenId, (s) => ({ ...s, ...patch }))
1383
1490
  }
1384
1491
 
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { starterDatasets, getStarterDataset } from './datasets.js'
3
+ import { crudAppFromSchemas } from '../screen-suites.js'
4
+ import { validateProject, parseProject, serializeProject } from '../project.js'
5
+
6
+ describe('starter datasets', () => {
7
+ it('ships a picker-ready gallery with unique ids', () => {
8
+ expect(starterDatasets.length).toBeGreaterThanOrEqual(4)
9
+ const ids = starterDatasets.map((d) => d.id)
10
+ expect(new Set(ids).size).toBe(ids.length)
11
+ for (const d of starterDatasets) {
12
+ expect(d.name).toBeTruthy()
13
+ expect(d.description).toBeTruthy()
14
+ expect(d.emoji).toBeTruthy()
15
+ }
16
+ })
17
+
18
+ it('getStarterDataset resolves by id', () => {
19
+ expect(getStarterDataset('customers-orders')?.name).toBe('Customers & orders')
20
+ expect(getStarterDataset('nope')).toBeUndefined()
21
+ })
22
+
23
+ it('build() returns fresh data each call (never a shared mutable seed)', () => {
24
+ const dataset = starterDatasets[0]!
25
+ const a = dataset.build()
26
+ const b = dataset.build()
27
+ expect(a).toEqual(b)
28
+ expect(a.seed).not.toBe(b.seed)
29
+ expect(a.entities[0]).not.toBe(b.entities[0])
30
+ })
31
+
32
+ for (const dataset of starterDatasets) {
33
+ describe(dataset.id, () => {
34
+ const { entities, seed } = dataset.build()
35
+
36
+ it('pairs a parent and a child linked by a real relation', () => {
37
+ expect(entities).toHaveLength(2)
38
+ const names = new Set(entities.map((e) => e.name))
39
+ const relations = entities.flatMap((e) => e.fields.filter((f) => f.type === 'relation'))
40
+ expect(relations.length).toBeGreaterThanOrEqual(1)
41
+ for (const r of relations) expect(names.has(r.relation!.entity)).toBe(true)
42
+ })
43
+
44
+ it('has a colored enum (so the generated app gets status pills and a chart)', () => {
45
+ const colored = entities.flatMap((e) => e.fields).filter((f) => f.type === 'enum' && f.options?.some((o) => o.color))
46
+ expect(colored.length).toBeGreaterThanOrEqual(1)
47
+ })
48
+
49
+ it('seeds every entity with enough rows to scroll', () => {
50
+ for (const e of entities) {
51
+ expect(seed[e.name], `${e.name} has no seed`).toBeTruthy()
52
+ expect(seed[e.name]!.length).toBeGreaterThanOrEqual(10)
53
+ }
54
+ })
55
+
56
+ it('seed rows carry unique ids and resolvable foreign keys', () => {
57
+ for (const e of entities) {
58
+ const pk = e.idField ?? 'id'
59
+ const values = seed[e.name]!.map((r) => r[pk])
60
+ expect(values.every((v) => v != null && v !== '')).toBe(true)
61
+ expect(new Set(values).size).toBe(values.length)
62
+ }
63
+ for (const child of entities) {
64
+ for (const f of child.fields) {
65
+ if (f.type !== 'relation') continue
66
+ const parent = entities.find((e) => e.name === f.relation!.entity)!
67
+ const parentPk = parent.idField ?? 'id'
68
+ const pool = new Set(seed[parent.name]!.map((r) => String(r[parentPk])))
69
+ for (const row of seed[child.name]!) {
70
+ expect(pool.has(String(row[f.field])), `${child.name}.${f.field} -> ${row[f.field]}`).toBe(true)
71
+ }
72
+ }
73
+ }
74
+ })
75
+
76
+ it('generates a valid app through crudAppFromSchemas', () => {
77
+ const app = crudAppFromSchemas(entities, { title: dataset.name, seed })
78
+ expect(validateProject(app).filter((i) => i.level === 'error')).toEqual([])
79
+ expect(app.screens.length).toBeGreaterThanOrEqual(3)
80
+ expect(parseProject(serializeProject(app))).toEqual(app)
81
+ })
82
+ })
83
+ }
84
+ })