@svgrid/enterprise 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +13 -1
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
@@ -91,4 +91,21 @@ describe('emitStudioApp', () => {
91
91
  it('throws on an empty entity set', () => {
92
92
  expect(() => emitStudioApp([])).toThrow(/no schemas/)
93
93
  })
94
+
95
+ it('types a chips/tags field as string[] (it holds + seeds an array)', () => {
96
+ const files = emitStudioApp([
97
+ {
98
+ name: 'contacts',
99
+ label: 'Contact',
100
+ idField: 'id',
101
+ fields: [
102
+ { field: 'id', type: 'text', primaryKey: true },
103
+ { field: 'tags', type: 'text', input: { editorType: 'chips' } },
104
+ ],
105
+ },
106
+ ])
107
+ const schemas = files.find((f) => f.path === 'src/lib/schemas.ts')!.contents
108
+ expect(schemas).toMatch(/tags: string\[\]/)
109
+ expect(schemas).not.toMatch(/tags: string(?!\[)/) // not the scalar `string`
110
+ })
94
111
  })
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { resolveIdField, titleCase, type EntityField, type EntityFieldType, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
14
14
  import type { GeneratedFile } from './scaffold.js'
15
- import type { EntityDataSource, RestSource, ShellConfig, ShellStyle, SqlDialectKind, EntityTriggers, TriggerEvent } from './project.js'
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'
17
17
 
18
18
  /** Build the createKitHandlers `hooks: {...}` option from an entity's triggers.
@@ -48,7 +48,11 @@ const camel = (name: string): string => {
48
48
  }
49
49
 
50
50
  /** Entity field type -> a TS property type for the generated row type. */
51
- function tsType(type: EntityFieldType): string {
51
+ function tsType(field: EntityField): string {
52
+ // A chips/tags editor stores multiple values (string[]) - its seed rows + form
53
+ // value are arrays, so the row type must be string[] even on a `text` field.
54
+ if (field.input?.editorType === 'chips') return 'string[]'
55
+ const type = field.type
52
56
  if (type === 'number') return 'number'
53
57
  if (type === 'boolean') return 'boolean'
54
58
  if (type === 'json') return 'unknown'
@@ -82,8 +86,10 @@ export function namesFor(schema: EntitySchema): Names {
82
86
  export const lookupVar = (schema: EntitySchema, field: string) => `${camel(schema.name)}${pascal(field)}Lookup`
83
87
 
84
88
  function rowType(schema: EntitySchema, derived: Set<string>): string {
85
- // Derived columns (relation labels) are filled at runtime, so they're optional.
86
- const lines = schema.fields.map((f) => ` ${f.field}${derived.has(f.field) ? '?' : ''}: ${tsType(f.type)}`)
89
+ // Runtime-filled columns are optional in the row type: relation labels (`derived`)
90
+ // and computed / no-code-formula fields (not present on seed rows / inserts).
91
+ const optional = (f: EntityField) => derived.has(f.field) || !!f.formula || !!f.computed
92
+ const lines = schema.fields.map((f) => ` ${f.field}${optional(f) ? '?' : ''}: ${tsType(f)}`)
87
93
  return `export type ${pascal(schema.name)} = {\n${lines.join('\n')}\n}`
88
94
  }
89
95
 
@@ -293,6 +299,23 @@ function sqlDialectExpr(dialect?: SqlDialectKind): string | null {
293
299
 
294
300
  const sq = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
295
301
 
302
+ /** The generator name for a REST adapter kind. */
303
+ export function restAdapterImport(a: RestAdapterConfig): string {
304
+ return a.kind === 'dummyjson' ? 'dummyJsonAdapter' : a.kind === 'jsonServer' ? 'jsonServerAdapter' : 'offsetLimitAdapter'
305
+ }
306
+ /** The adapter spread emitted into the createRestDataSource(...) call - it supplies
307
+ * a `buildQuery` (real server paging/sort) + `parse` (row/total unwrap). */
308
+ function restAdapterExpr(a: RestAdapterConfig, T: string): string {
309
+ if (a.kind === 'dummyjson') return `...dummyJsonAdapter<${T}>(${a.rowsKey ? sq(a.rowsKey) : ''})`
310
+ if (a.kind === 'jsonServer') return `...jsonServerAdapter<${T}>()`
311
+ const fields: string[] = []
312
+ for (const k of ['offsetParam', 'limitParam', 'sortByParam', 'orderParam', 'searchParam', 'rowsKey', 'totalKey'] as const) {
313
+ const v = a[k]
314
+ if (v) fields.push(`${k}: ${sq(v)}`)
315
+ }
316
+ return `...offsetLimitAdapter<${T}>(${fields.length ? `{ ${fields.join(', ')} }` : ''})`
317
+ }
318
+
296
319
  /** The `createRestDataSource(...)` call for one entity's REST binding. */
297
320
  function restStoreExpr(src: RestSource, storeName: string, T: string, schemaVar: string): string {
298
321
  const opts: string[] = [`url: ${sq(buildRestUrl(src))}`, `schema: ${schemaVar}`]
@@ -301,7 +324,11 @@ function restStoreExpr(src: RestSource, storeName: string, T: string, schemaVar:
301
324
  if (headers.length) opts.push(`headers: { ${headers.map((h) => `${sq(h.name)}: ${sq(h.value ?? '')}`).join(', ')} }`)
302
325
  const query = src.params.filter((p) => p.location === 'query' && p.value !== undefined && p.value !== '')
303
326
  if (query.length) opts.push(`query: { ${query.map((q) => `${sq(q.name)}: ${sq(q.value ?? '')}`).join(', ')} }`)
304
- if (src.rowsPath || src.totalPath) {
327
+ // An adapter provides both buildQuery (server paging/sort) + parse, so it wins over
328
+ // the legacy manual rowsPath/totalPath (which only unwraps a single fetched page).
329
+ if (src.adapter) {
330
+ opts.push(restAdapterExpr(src.adapter, T))
331
+ } else if (src.rowsPath || src.totalPath) {
305
332
  const rowsExpr = src.rowsPath ? dottedAccess('body', src.rowsPath) : 'body'
306
333
  const totalExpr = src.totalPath ? dottedAccess('body', src.totalPath) : `(${rowsExpr})?.length`
307
334
  opts.push(`parse: (body: any) => ({ rows: ${rowsExpr} ?? [], rowCount: Number(${totalExpr} ?? 0) })`)
@@ -314,7 +341,7 @@ type DataModuleNeeds = {
314
341
  supabaseUrl?: string
315
342
  supabaseKey?: string
316
343
  /** SQL-bound entities get a connected `+server.ts` API route each. */
317
- sqlRoutes: Array<{ schema: EntitySchema; table: string; dialect?: SqlDialectKind }>
344
+ sqlRoutes: Array<{ schema: EntitySchema; table: string; dialect?: SqlDialectKind; dbSchema?: string }>
318
345
  /** Embedded-Postgres (PGlite) entities: one shared client + a table each. */
319
346
  pglite?: boolean
320
347
  pgliteTables?: Array<{ schema: EntitySchema; table: string; seed?: Record<string, unknown>[] }>
@@ -335,7 +362,7 @@ const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', {
335
362
  * the route imports the shared access policy and rejects unauthorized writes -
336
363
  * server-enforced, so a tampered client can't bypass it. When audit is on, every
337
364
  * successful write is recorded. */
338
- function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers } = {}): GeneratedFile {
365
+ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers; dbSchema?: string } = {}): GeneratedFile {
339
366
  const n = namesFor(schema)
340
367
  const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
341
368
  const driver = SQL_DRIVERS[key]
@@ -363,7 +390,7 @@ ${driver.setup}
363
390
 
364
391
  const source = createSqlDataSource<${n.type}>({
365
392
  schema: ${n.schemaVar},
366
- table: ${JSON.stringify(table)},${dialectLiteral ? `\n dialect: ${dialectLiteral},` : ''}
393
+ table: ${JSON.stringify(table)},${feat.dbSchema ? `\n dbSchema: ${JSON.stringify(feat.dbSchema)},` : ''}${dialectLiteral ? `\n dialect: ${dialectLiteral},` : ''}
367
394
  execute: async (text, params) => {
368
395
  ${driver.exec}
369
396
  },
@@ -418,10 +445,89 @@ async function _pgSeed(table: string, rows: Record<string, unknown>[], cols: str
418
445
  `
419
446
  }
420
447
 
448
+ // ---------------------------------------------------------------------------
449
+ // db/schema.sql + db/seed.sql - plain SQL a user runs against their database
450
+ // before first deploy, for SQL entities WITHOUT the Drizzle data layer (which
451
+ // owns DDL via migrations when active). This closes the "tables must already
452
+ // exist" cliff on every dialect - including MSSQL, which Drizzle can't cover.
453
+ // ---------------------------------------------------------------------------
454
+
455
+ type DdlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql'
456
+
457
+ /** Normalize a source dialect to a DDL family (supabase = postgres, turso = sqlite). */
458
+ const ddlDialect = (d?: SqlDialectKind): DdlDialect =>
459
+ d === 'mysql' ? 'mysql' : d === 'sqlite' || d === 'turso' ? 'sqlite' : d === 'mssql' ? 'mssql' : 'postgres'
460
+
461
+ /** Column types per dialect (default column type = the `text` entry). */
462
+ const DDL_COL_TYPES: Record<DdlDialect, Record<string, string>> = {
463
+ postgres: { text: 'text', number: 'double precision', boolean: 'boolean', date: 'date', dateString: 'date', datetime: 'timestamptz', json: 'jsonb' },
464
+ mysql: { text: 'varchar(255)', number: 'double', boolean: 'tinyint(1)', date: 'date', dateString: 'date', datetime: 'datetime', json: 'json' },
465
+ sqlite: { text: 'text', number: 'real', boolean: 'integer', date: 'text', dateString: 'text', datetime: 'text', json: 'text' },
466
+ mssql: { text: 'nvarchar(255)', number: 'float', boolean: 'bit', date: 'date', dateString: 'date', datetime: 'datetime2', json: 'nvarchar(max)' },
467
+ }
468
+
469
+ /** Identifier quoter per dialect (doubles the quote char to escape, like sql.ts). */
470
+ function ddlQuoter(dialect: DdlDialect): (name: string) => string {
471
+ const q = dialect === 'mysql' ? '`' : '"'
472
+ return (name) => `${q}${name.replaceAll(q, q + q)}${q}`
473
+ }
474
+
475
+ /** A SQL literal for a seed value, per dialect. */
476
+ function ddlLiteral(v: unknown, dialect: DdlDialect): string {
477
+ if (v == null || v === '') return 'NULL'
478
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL'
479
+ if (typeof v === 'boolean') return dialect === 'postgres' ? (v ? 'TRUE' : 'FALSE') : v ? '1' : '0'
480
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v)
481
+ const escaped = s.replaceAll("'", "''")
482
+ return dialect === 'mssql' ? `N'${escaped}'` : `'${escaped}'`
483
+ }
484
+
485
+ /** `db/schema.sql` + `db/seed.sql` for the project's SQL-bound entities. */
486
+ export function sqlDdlFiles(schemas: EntitySchema[], sources: Record<string, EntityDataSource>): GeneratedFile[] {
487
+ const { entries, seed } = prepareEntities(schemas)
488
+ const sqlEntries = entries
489
+ .map((e) => ({ ...e, src: sources[e.schema.name] }))
490
+ .filter((e): e is Prepared & { src: SqlSource } => e.src?.kind === 'sql')
491
+ if (sqlEntries.length === 0) return []
492
+
493
+ const create: string[] = []
494
+ const inserts: string[] = []
495
+ for (const { schema, infos, src } of sqlEntries) {
496
+ const dialect = ddlDialect(src.dialect)
497
+ const q = ddlQuoter(dialect)
498
+ const types = DDL_COL_TYPES[dialect]
499
+ const pk = resolveIdField(schema)
500
+ // Relation display fields are filled at runtime (labels), not stored.
501
+ const display = new Set(infos.map((i) => i.displayField))
502
+ const cols = schema.fields.filter((f) => !display.has(f.field) && !f.formula && !f.computed)
503
+ const colDefs = cols.map((f) => ` ${q(f.field)} ${types[f.type] ?? types.text}${f.field === pk ? ' PRIMARY KEY' : ''}`)
504
+ const body = `CREATE TABLE ${dialect === 'mssql' ? '' : 'IF NOT EXISTS '}${q(src.table)} (\n${colDefs.join(',\n')}\n)`
505
+ create.push(dialect === 'mssql' ? `IF OBJECT_ID(N'${src.table.replaceAll("'", "''")}', N'U') IS NULL\n${body};` : `${body};`)
506
+
507
+ const rows = seed.get(schema.name) ?? []
508
+ if (rows.length) {
509
+ const names = cols.map((f) => q(f.field)).join(', ')
510
+ const values = rows.map((r) => ` (${cols.map((f) => ddlLiteral(r[f.field], dialect)).join(', ')})`).join(',\n')
511
+ inserts.push(`INSERT INTO ${q(src.table)} (${names}) VALUES\n${values};`)
512
+ }
513
+ }
514
+
515
+ const header = (what: string) =>
516
+ `-- ${what} for the app's SQL entities. Generated by SvGrid Studio.\n` +
517
+ `-- Run against the database your DATABASE_URL points at (psql/mysql/sqlite3/sqlcmd,\n` +
518
+ `-- or your provider's SQL console) before first use. Tip: the Drizzle data layer\n` +
519
+ `-- replaces this file with real, versioned migrations.\n\n`
520
+ return [
521
+ { path: 'db/schema.sql', description: 'CREATE TABLE statements for the SQL entities (run once against your database).', contents: header('Table definitions') + create.join('\n\n') + '\n' },
522
+ ...(inserts.length ? [{ path: 'db/seed.sql', description: 'Optional sample rows for the SQL entities.', contents: header('Sample seed rows (optional)') + inserts.join('\n\n') + '\n' }] : []),
523
+ ]
524
+ }
525
+
421
526
  function dataModule(
422
527
  entries: Prepared[],
423
528
  seed: Map<string, Array<Record<string, unknown>>>,
424
529
  sources?: Record<string, EntityDataSource>,
530
+ supabaseConn?: { url?: string; key?: string },
425
531
  ): { file: GeneratedFile; needs: DataModuleNeeds } {
426
532
  const schemas = entries.map((e) => e.schema)
427
533
  // Each type gets its own `type` keyword - SvelteKit enables verbatimModuleSyntax.
@@ -442,15 +548,17 @@ function dataModule(
442
548
  switch (src.kind) {
443
549
  case 'rest':
444
550
  entImports.add('createRestDataSource')
551
+ if (src.adapter) entImports.add(restAdapterImport(src.adapter))
445
552
  return restStoreExpr(src, store, T, sv)
446
553
  case 'sql':
447
554
  // SQL runs server-side: the client talks to a generated /api/<entity> route.
448
555
  entImports.add('createKitDataSource')
449
- needs.sqlRoutes.push({ schema: e.schema, table: src.table, dialect: src.dialect })
556
+ needs.sqlRoutes.push({ schema: e.schema, table: src.table, dialect: src.dialect, dbSchema: src.schema })
450
557
  return `const ${store} = createKitDataSource<${T}>({ endpoint: '/api/${namesFor(e.schema).route}' })`
451
558
  case 'supabase':
452
559
  entImports.add('createSupabaseDataSource')
453
560
  needs.supabase = true
561
+ // Per-entity url/key overrides the project-level connection for the .env hint.
454
562
  if (src.url && src.key && !needs.supabaseUrl) { needs.supabaseUrl = src.url; needs.supabaseKey = src.key }
455
563
  return `const ${store} = createSupabaseDataSource<${T}>({ client: supabaseClient, table: ${sq(src.table)}, schema: ${sv} })`
456
564
  case 'pglite': {
@@ -495,6 +603,11 @@ function dataModule(
495
603
  })
496
604
  .join('\n')
497
605
 
606
+ // Project-level connection fills the .env hint when no entity carries its own url/key.
607
+ if (needs.supabase && !needs.supabaseUrl && (supabaseConn?.url || supabaseConn?.key)) {
608
+ needs.supabaseUrl = supabaseConn.url
609
+ needs.supabaseKey = supabaseConn.key
610
+ }
498
611
  const connImportLine = needs.supabase ? `\nimport { supabaseClient } from './connections'` : ''
499
612
  const pgliteImportLine = needs.pglite ? `\nimport { PGlite } from '@electric-sql/pglite'` : ''
500
613
  const pgliteBoot = needs.pglite ? pgliteBootstrap(needs.pgliteTables!, seed) + '\n' : ''
@@ -523,27 +636,30 @@ export const nextId = (prefix: string) => \`\${prefix}\${++seq}\`
523
636
  return { file, needs }
524
637
  }
525
638
 
526
- /** The connection stubs for SQL / Supabase sources - the one manual wiring step. */
639
+ /** The Supabase client wiring. Reads the project URL + anon key from PUBLIC_ env vars
640
+ * (never inlined into source); the anon key is public / browser-safe, so this is a
641
+ * secret-hygiene win and matches SvelteKit convention. The designer-entered values are
642
+ * shown as a comment so they can be pasted straight into `.env` / `.env.example`. */
527
643
  function connectionsModule(needs: DataModuleNeeds): GeneratedFile {
528
- const realSupabase = !!(needs.supabaseUrl && needs.supabaseKey)
529
- const valueImport = realSupabase ? `import { createClient } from '@supabase/supabase-js'\n` : ''
530
- const body = realSupabase
531
- ? `/**
532
- * Supabase client. The anon key is public (browser-safe) - access is protected
533
- * by your Row Level Security policies. Requires \`@supabase/supabase-js\`
534
- * (\`npm i @supabase/supabase-js\`).
535
- */
536
- export const supabaseClient = createClient(${sq(needs.supabaseUrl!)}, ${sq(needs.supabaseKey!)}) as unknown as SupabaseClientLike`
537
- : `/**
538
- * TODO: connect Supabase. Add your project URL + anon key in the Studio designer,
539
- * or replace this null stub with a real client:
540
- * \`import { createClient } from '@supabase/supabase-js'; export const supabaseClient = createClient(url, anonKey)\`.
541
- */
542
- export const supabaseClient = null as unknown as SupabaseClientLike`
644
+ const hint = needs.supabaseUrl || needs.supabaseKey
645
+ ? `// From the Studio designer - paste into .env:\n// PUBLIC_SUPABASE_URL=${needs.supabaseUrl ?? ''}\n// PUBLIC_SUPABASE_ANON_KEY=${needs.supabaseKey ?? ''}\n`
646
+ : `// Set PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY in .env (see .env.example).\n`
543
647
  return {
544
648
  path: 'src/lib/connections.ts',
545
- description: 'Supabase client for Supabase-bound entities.',
546
- contents: `${valueImport}import type { SupabaseClientLike } from '@svgrid/enterprise'\n\n${body}\n`,
649
+ description: 'Supabase client for Supabase-bound entities (reads PUBLIC_SUPABASE_* env vars).',
650
+ contents: `import { createClient } from '@supabase/supabase-js'
651
+ import { env } from '$env/dynamic/public'
652
+ import type { SupabaseClientLike } from '@svgrid/enterprise'
653
+
654
+ /**
655
+ * Supabase client. The anon key is public (browser-safe) - access is protected by
656
+ * your Row Level Security policies. Requires \`@supabase/supabase-js\`.
657
+ */
658
+ ${hint}export const supabaseClient = createClient(
659
+ env.PUBLIC_SUPABASE_URL ?? '',
660
+ env.PUBLIC_SUPABASE_ANON_KEY ?? '',
661
+ ) as unknown as SupabaseClientLike
662
+ `,
547
663
  }
548
664
  }
549
665
 
@@ -580,7 +696,7 @@ export function entityScreenPage(schema: EntitySchema, route?: string, title?: s
580
696
 
581
697
  export type NavItem = { href: string; label: string; id?: string }
582
698
 
583
- export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; lightVars?: Record<string, string>; darkVars?: Record<string, string>; dark?: boolean; access?: boolean; auth?: boolean; authRoutes?: string[]; authAccount?: boolean; i18n?: boolean; appClass?: string } = {}): GeneratedFile {
699
+ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; lightVars?: Record<string, string>; darkVars?: Record<string, string>; dark?: boolean; access?: boolean; auth?: boolean; supabaseAuth?: boolean; authRoutes?: string[]; authAccount?: boolean; i18n?: boolean; appClass?: string } = {}): GeneratedFile {
584
700
  // Nav is the app's own screens; `/` just redirects to the first one, so no separate
585
701
  // "Home" link (it would duplicate the first screen).
586
702
  const links = nav
@@ -736,7 +852,9 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
736
852
  <button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>`}
737
853
  ${opts.auth
738
854
  ? `<form method="POST" action="/logout" class="sv-app__signout"><button type="submit" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem">Sign out</button></form>`
739
- : `<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>`}
855
+ : opts.supabaseAuth
856
+ ? '' // Supabase Auth: the real sign-out lives on SvAuthGate's own bar, so no placeholder here.
857
+ : `<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>`}
740
858
  </div>
741
859
  {/if}
742
860
  </div>
@@ -744,9 +862,17 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
744
862
  {#if bellOpen || menuOpen}<button type="button" class="sv-app__scrim" tabindex="-1" aria-label="Close menus" onclick={() => { bellOpen = false; menuOpen = false }}></button>{/if}
745
863
  </div>\n `
746
864
  : ''
865
+ // Supabase Auth: gate the whole app behind a client-side sign-in. SvAuthGate shows
866
+ // the sign-in form when signed out, and the app + a "signed in as … / Sign out" bar
867
+ // when signed in. Reads the shared client from $lib/connections.
868
+ const content = opts.supabaseAuth
869
+ ? `<SvAuthGate client={supabaseClient as unknown as SupabaseAuthClientLike} title={\`Sign in to \${brand}\`}>
870
+ {@render children()}
871
+ </SvAuthGate>`
872
+ : `{@render children()}`
747
873
  const mainMarkup = `<main class="sv-app__main">
748
874
  ${toolbarMarkup}<div class="sv-app__content">
749
- {@render children()}
875
+ ${content}
750
876
  </div>
751
877
  </main>`
752
878
 
@@ -842,7 +968,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
842
968
  contents: `<script lang="ts">
843
969
  import '../app.css'
844
970
  import '../custom.css'
845
- 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'` : ''}
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'` : ''}
846
972
 
847
973
  let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
848
974
  const nav = ${JSON.stringify(links)}
@@ -1011,13 +1137,18 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
1011
1137
  /** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
1012
1138
  export function emitEntityModules(
1013
1139
  schemas: EntitySchema[],
1014
- opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers> } = {},
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 } = {},
1015
1141
  ): { files: GeneratedFile[]; prepared: EntitySchema[] } {
1016
1142
  const { entries, seed } = prepareEntities(schemas)
1017
- const { file: data, needs } = dataModule(entries, seed, opts.sources)
1143
+ const { file: data, needs } = dataModule(entries, seed, opts.sources, opts.supabaseConn)
1018
1144
  const files: GeneratedFile[] = [schemasModule(entries), data]
1019
- if (needs.supabase) files.push(connectionsModule(needs))
1020
- 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] }))
1145
+ // Supabase Auth needs the shared client even with no Supabase-bound entity; seed
1146
+ // the .env paste hint from the project connection when nothing else did.
1147
+ if ((needs.supabase || opts.supabaseAuth) && !needs.supabaseUrl && (opts.supabaseConn?.url || opts.supabaseConn?.key)) {
1148
+ needs.supabaseUrl = opts.supabaseConn.url; needs.supabaseKey = opts.supabaseConn.key
1149
+ }
1150
+ 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 }))
1021
1152
  return { files, prepared: entries.map((e) => e.schema) }
1022
1153
  }
1023
1154
 
@@ -8,6 +8,7 @@ export { inferType, introspectJson, introspectDrizzle, introspectDrizzleAll } fr
8
8
  export { refineField, refineFields } from '../sources/field-inference.js'
9
9
  export { parseCsv, detectDelimiter, csvToEntity, type CsvEntity } from './csv.js'
10
10
  export { introspectPrisma, introspectPrismaAll } from './introspect-prisma.js'
11
+ export { introspectOpenApi, type OpenApiImport } from './introspect-openapi.js'
11
12
  export {
12
13
  introspectDatabase,
13
14
  listDatabaseTables,
@@ -39,8 +40,9 @@ export {
39
40
  } from './scaffold.js'
40
41
  export { scaffoldApp, type ScaffoldAppOptions } from './scaffold-app.js'
41
42
  export { emitStudioApp, emitEntityModules, entityScreenPage, prepareEntities } from './emit-schema.js'
42
- export { emitStudioProject, emitStudioAppBundle, studioDeployInfo, screenHandles, screenDataset, ctxCompletions, componentHandleMembers, ctxAmbientDts, GRID_API_MEMBERS, type BlockHandle, type HandleTier } from './emit-project.js'
43
+ export { emitStudioProject, emitStudioAppBundle, emitStudioFragment, runtimeDeps, studioDeployInfo, screenHandles, screenDataset, ctxCompletions, componentHandleMembers, ctxAmbientDts, GRID_API_MEMBERS, type BlockHandle, type HandleTier } from './emit-project.js'
43
44
  export { sampleApps, getSampleApp, type SampleApp } from './samples/index.js'
45
+ export { starterProject } from './samples/starter.js'
44
46
  export { liveDataSamples, getLiveDataSample } from './samples/live-data.js'
45
47
  export {
46
48
  sanitizeStudioProject,
@@ -133,9 +135,15 @@ export {
133
135
  type TriggerEvent,
134
136
  type EntityTriggers,
135
137
  clickSlot,
138
+ ssrScreenShape,
139
+ ssrEligible,
140
+ setScreenRenderMode,
141
+ eventSlot,
136
142
  rowSelectSlot,
137
143
  changeSlot,
138
144
  FORM_SUBMIT,
145
+ GRID_EVENTS,
146
+ type GridEventDef,
139
147
  addStateVar,
140
148
  updateStateVar,
141
149
  removeStateVar,
@@ -263,6 +271,7 @@ export {
263
271
  type EntityDataSource,
264
272
  type MemorySource,
265
273
  type RestSource,
274
+ type RestAdapterConfig,
266
275
  type SqlSource,
267
276
  type SupabaseSource,
268
277
  type PgliteSource,
@@ -288,10 +297,25 @@ export {
288
297
  export {
289
298
  UI_COMPONENT_REGISTRY,
290
299
  uiComponentSpec,
300
+ gridPropSurface,
301
+ gridApiSettableProps,
302
+ GRID_CURATED_PROPS,
303
+ STANDARD_UI_EVENTS,
291
304
  type UiPropType,
305
+ type UiPropGroup,
306
+ type UiComponentEvent,
292
307
  type UiComponentProp,
293
308
  type UiComponentSpec,
294
309
  } from './ui-components.js'
310
+ export { buildCopilotMessages, projectFromModelText, type CopilotMessages } from './copilot-core.js'
311
+ export {
312
+ resolveDeployTarget,
313
+ deployCommands,
314
+ missingEnvKeys,
315
+ type DeployProvider,
316
+ type DeployResolution,
317
+ type DeployPlanCommands,
318
+ } from './deploy-cli.js'
295
319
  export {
296
320
  runStudioAdd,
297
321
  runStudioAddApp,
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { introspectOpenApi } from './introspect-openapi'
3
+
4
+ // A petstore-ish spec: bare-array list, $ref item schema, an enum, a nested $ref
5
+ // relation (pet.category -> Category), and a wrapped-list resource (orders).
6
+ const spec = {
7
+ openapi: '3.0.3',
8
+ servers: [{ url: 'https://api.example.com/v1/' }],
9
+ paths: {
10
+ '/pets': {
11
+ get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } } } } } }, parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }] },
12
+ post: { requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } },
13
+ },
14
+ '/pets/{petId}': {
15
+ get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } } },
16
+ put: { responses: { '200': {} } },
17
+ delete: { responses: { '204': {} } },
18
+ },
19
+ '/categories': { get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Category' } } } } } } } },
20
+ '/orders': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderList' } } } } } } },
21
+ },
22
+ components: {
23
+ schemas: {
24
+ Pet: {
25
+ type: 'object',
26
+ required: ['name'],
27
+ properties: {
28
+ id: { type: 'integer' },
29
+ name: { type: 'string' },
30
+ status: { type: 'string', enum: ['available', 'pending', 'sold'] },
31
+ bornOn: { type: 'string', format: 'date' },
32
+ category: { $ref: '#/components/schemas/Category' },
33
+ tags: { type: 'array', items: { type: 'string' } },
34
+ },
35
+ },
36
+ Category: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } },
37
+ Order: { type: 'object', properties: { id: { type: 'integer' }, total: { type: 'number' }, placedAt: { type: 'string', format: 'date-time' } } },
38
+ OrderList: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Order' } }, total: { type: 'integer' } } },
39
+ },
40
+ },
41
+ }
42
+
43
+ describe('introspectOpenApi', () => {
44
+ const out = introspectOpenApi(JSON.stringify(spec))
45
+
46
+ it('groups paths into one entity + one REST source per resource', () => {
47
+ expect(out.entities.map((e) => e.name).sort()).toEqual(['category', 'order', 'pet'])
48
+ const pet = out.sources.pet
49
+ expect(pet).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com/v1', path: 'pets', method: 'GET', idField: 'id' })
50
+ // query parameters carried onto the source
51
+ if (pet?.kind === 'rest') expect(pet.params).toEqual([{ name: 'status', location: 'query', type: 'string' }])
52
+ })
53
+
54
+ it('maps JSON-Schema types + formats + enums + pk', () => {
55
+ const pet = out.entities.find((e) => e.name === 'pet')!
56
+ const byName = new Map(pet.fields.map((f) => [f.field, f]))
57
+ expect(byName.get('id')).toMatchObject({ type: 'number', primaryKey: true })
58
+ expect(byName.get('name')).toMatchObject({ type: 'text', required: true })
59
+ expect(byName.get('status')).toMatchObject({ type: 'enum' })
60
+ expect(byName.get('status')!.options!.map((o) => o.value)).toEqual(['available', 'pending', 'sold'])
61
+ expect(byName.get('bornOn')!.type).toBe('dateString')
62
+ expect(byName.get('tags')!.type).toBe('json')
63
+ })
64
+
65
+ it('turns a $ref property into a relation to the referenced entity', () => {
66
+ const pet = out.entities.find((e) => e.name === 'pet')!
67
+ const category = pet.fields.find((f) => f.field === 'category')!
68
+ expect(category).toMatchObject({ type: 'relation', relation: { entity: 'category', foreignKey: 'category' } })
69
+ })
70
+
71
+ it('detects a wrapped list response (rowsPath = data) + item schema behind it', () => {
72
+ const src = out.sources.order
73
+ expect(src?.kind === 'rest' ? src.rowsPath : null).toBe('data')
74
+ const order = out.entities.find((e) => e.name === 'order')!
75
+ expect(order.fields.map((f) => f.field).sort()).toEqual(['id', 'placedAt', 'total'])
76
+ expect(order.fields.find((f) => f.field === 'placedAt')!.type).toBe('datetime')
77
+ })
78
+
79
+ it('rejects non-JSON and non-OpenAPI input with clear errors', () => {
80
+ expect(() => introspectOpenApi('name: swagger')).toThrow(/JSON only/)
81
+ expect(() => introspectOpenApi('{"foo":1}')).toThrow(/no "paths"/)
82
+ expect(() => introspectOpenApi('{"paths":{}}')).toThrow(/No REST resources/)
83
+ })
84
+ })