@svgrid/enterprise 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. 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
  })
@@ -10,9 +10,9 @@
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
- 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,17 +362,19 @@ 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; tenantField?: 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]
342
369
  const dialectLiteral = sqlDialectExpr(dialect)
343
370
  const accessImport = feat.access ? `\nimport { authorizeAction, getServerRole } from '$lib/access'` : ''
344
371
  const auditImport = feat.audit ? `\nimport { recordAudit } from '$lib/audit'` : ''
372
+ const tenantImport = feat.tenantField ? `\nimport { requireTenant } from '$lib/server/tenant'` : ''
345
373
  // Every connected route validates writes against the schema server-side, and
346
374
  // (when enabled) authorizes them by role + records an audit entry.
347
375
  const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
348
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) })`)
349
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') })`)
350
379
  // Server-enforced business rules: the entity's triggers compiled to lifecycle hooks.
351
380
  const hooks = triggerHooksOpt(feat.triggers)
@@ -357,13 +386,13 @@ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectK
357
386
  contents: `${driver.imports}
358
387
  import { env } from '$env/dynamic/private'
359
388
  import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'
360
- import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}
389
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}${tenantImport}
361
390
 
362
391
  ${driver.setup}
363
392
 
364
393
  const source = createSqlDataSource<${n.type}>({
365
394
  schema: ${n.schemaVar},
366
- table: ${JSON.stringify(table)},${dialectLiteral ? `\n dialect: ${dialectLiteral},` : ''}
395
+ table: ${JSON.stringify(table)},${feat.dbSchema ? `\n dbSchema: ${JSON.stringify(feat.dbSchema)},` : ''}${dialectLiteral ? `\n dialect: ${dialectLiteral},` : ''}
367
396
  execute: async (text, params) => {
368
397
  ${driver.exec}
369
398
  },
@@ -418,10 +447,89 @@ async function _pgSeed(table: string, rows: Record<string, unknown>[], cols: str
418
447
  `
419
448
  }
420
449
 
450
+ // ---------------------------------------------------------------------------
451
+ // db/schema.sql + db/seed.sql - plain SQL a user runs against their database
452
+ // before first deploy, for SQL entities WITHOUT the Drizzle data layer (which
453
+ // owns DDL via migrations when active). This closes the "tables must already
454
+ // exist" cliff on every dialect - including MSSQL, which Drizzle can't cover.
455
+ // ---------------------------------------------------------------------------
456
+
457
+ type DdlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql'
458
+
459
+ /** Normalize a source dialect to a DDL family (supabase = postgres, turso = sqlite). */
460
+ const ddlDialect = (d?: SqlDialectKind): DdlDialect =>
461
+ d === 'mysql' ? 'mysql' : d === 'sqlite' || d === 'turso' ? 'sqlite' : d === 'mssql' ? 'mssql' : 'postgres'
462
+
463
+ /** Column types per dialect (default column type = the `text` entry). */
464
+ const DDL_COL_TYPES: Record<DdlDialect, Record<string, string>> = {
465
+ postgres: { text: 'text', number: 'double precision', boolean: 'boolean', date: 'date', dateString: 'date', datetime: 'timestamptz', json: 'jsonb' },
466
+ mysql: { text: 'varchar(255)', number: 'double', boolean: 'tinyint(1)', date: 'date', dateString: 'date', datetime: 'datetime', json: 'json' },
467
+ sqlite: { text: 'text', number: 'real', boolean: 'integer', date: 'text', dateString: 'text', datetime: 'text', json: 'text' },
468
+ mssql: { text: 'nvarchar(255)', number: 'float', boolean: 'bit', date: 'date', dateString: 'date', datetime: 'datetime2', json: 'nvarchar(max)' },
469
+ }
470
+
471
+ /** Identifier quoter per dialect (doubles the quote char to escape, like sql.ts). */
472
+ function ddlQuoter(dialect: DdlDialect): (name: string) => string {
473
+ const q = dialect === 'mysql' ? '`' : '"'
474
+ return (name) => `${q}${name.replaceAll(q, q + q)}${q}`
475
+ }
476
+
477
+ /** A SQL literal for a seed value, per dialect. */
478
+ function ddlLiteral(v: unknown, dialect: DdlDialect): string {
479
+ if (v == null || v === '') return 'NULL'
480
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL'
481
+ if (typeof v === 'boolean') return dialect === 'postgres' ? (v ? 'TRUE' : 'FALSE') : v ? '1' : '0'
482
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v)
483
+ const escaped = s.replaceAll("'", "''")
484
+ return dialect === 'mssql' ? `N'${escaped}'` : `'${escaped}'`
485
+ }
486
+
487
+ /** `db/schema.sql` + `db/seed.sql` for the project's SQL-bound entities. */
488
+ export function sqlDdlFiles(schemas: EntitySchema[], sources: Record<string, EntityDataSource>): GeneratedFile[] {
489
+ const { entries, seed } = prepareEntities(schemas)
490
+ const sqlEntries = entries
491
+ .map((e) => ({ ...e, src: sources[e.schema.name] }))
492
+ .filter((e): e is Prepared & { src: SqlSource } => e.src?.kind === 'sql')
493
+ if (sqlEntries.length === 0) return []
494
+
495
+ const create: string[] = []
496
+ const inserts: string[] = []
497
+ for (const { schema, infos, src } of sqlEntries) {
498
+ const dialect = ddlDialect(src.dialect)
499
+ const q = ddlQuoter(dialect)
500
+ const types = DDL_COL_TYPES[dialect]
501
+ const pk = resolveIdField(schema)
502
+ // Relation display fields are filled at runtime (labels), not stored.
503
+ const display = new Set(infos.map((i) => i.displayField))
504
+ const cols = schema.fields.filter((f) => !display.has(f.field) && !f.formula && !f.computed)
505
+ const colDefs = cols.map((f) => ` ${q(f.field)} ${types[f.type] ?? types.text}${f.field === pk ? ' PRIMARY KEY' : ''}`)
506
+ const body = `CREATE TABLE ${dialect === 'mssql' ? '' : 'IF NOT EXISTS '}${q(src.table)} (\n${colDefs.join(',\n')}\n)`
507
+ create.push(dialect === 'mssql' ? `IF OBJECT_ID(N'${src.table.replaceAll("'", "''")}', N'U') IS NULL\n${body};` : `${body};`)
508
+
509
+ const rows = seed.get(schema.name) ?? []
510
+ if (rows.length) {
511
+ const names = cols.map((f) => q(f.field)).join(', ')
512
+ const values = rows.map((r) => ` (${cols.map((f) => ddlLiteral(r[f.field], dialect)).join(', ')})`).join(',\n')
513
+ inserts.push(`INSERT INTO ${q(src.table)} (${names}) VALUES\n${values};`)
514
+ }
515
+ }
516
+
517
+ const header = (what: string) =>
518
+ `-- ${what} for the app's SQL entities. Generated by SvGrid Studio.\n` +
519
+ `-- Run against the database your DATABASE_URL points at (psql/mysql/sqlite3/sqlcmd,\n` +
520
+ `-- or your provider's SQL console) before first use. Tip: the Drizzle data layer\n` +
521
+ `-- replaces this file with real, versioned migrations.\n\n`
522
+ return [
523
+ { 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' },
524
+ ...(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' }] : []),
525
+ ]
526
+ }
527
+
421
528
  function dataModule(
422
529
  entries: Prepared[],
423
530
  seed: Map<string, Array<Record<string, unknown>>>,
424
531
  sources?: Record<string, EntityDataSource>,
532
+ supabaseConn?: { url?: string; key?: string },
425
533
  ): { file: GeneratedFile; needs: DataModuleNeeds } {
426
534
  const schemas = entries.map((e) => e.schema)
427
535
  // Each type gets its own `type` keyword - SvelteKit enables verbatimModuleSyntax.
@@ -442,15 +550,17 @@ function dataModule(
442
550
  switch (src.kind) {
443
551
  case 'rest':
444
552
  entImports.add('createRestDataSource')
553
+ if (src.adapter) entImports.add(restAdapterImport(src.adapter))
445
554
  return restStoreExpr(src, store, T, sv)
446
555
  case 'sql':
447
556
  // SQL runs server-side: the client talks to a generated /api/<entity> route.
448
557
  entImports.add('createKitDataSource')
449
- needs.sqlRoutes.push({ schema: e.schema, table: src.table, dialect: src.dialect })
558
+ needs.sqlRoutes.push({ schema: e.schema, table: src.table, dialect: src.dialect, dbSchema: src.schema })
450
559
  return `const ${store} = createKitDataSource<${T}>({ endpoint: '/api/${namesFor(e.schema).route}' })`
451
560
  case 'supabase':
452
561
  entImports.add('createSupabaseDataSource')
453
562
  needs.supabase = true
563
+ // Per-entity url/key overrides the project-level connection for the .env hint.
454
564
  if (src.url && src.key && !needs.supabaseUrl) { needs.supabaseUrl = src.url; needs.supabaseKey = src.key }
455
565
  return `const ${store} = createSupabaseDataSource<${T}>({ client: supabaseClient, table: ${sq(src.table)}, schema: ${sv} })`
456
566
  case 'pglite': {
@@ -495,6 +605,11 @@ function dataModule(
495
605
  })
496
606
  .join('\n')
497
607
 
608
+ // Project-level connection fills the .env hint when no entity carries its own url/key.
609
+ if (needs.supabase && !needs.supabaseUrl && (supabaseConn?.url || supabaseConn?.key)) {
610
+ needs.supabaseUrl = supabaseConn.url
611
+ needs.supabaseKey = supabaseConn.key
612
+ }
498
613
  const connImportLine = needs.supabase ? `\nimport { supabaseClient } from './connections'` : ''
499
614
  const pgliteImportLine = needs.pglite ? `\nimport { PGlite } from '@electric-sql/pglite'` : ''
500
615
  const pgliteBoot = needs.pglite ? pgliteBootstrap(needs.pgliteTables!, seed) + '\n' : ''
@@ -523,27 +638,30 @@ export const nextId = (prefix: string) => \`\${prefix}\${++seq}\`
523
638
  return { file, needs }
524
639
  }
525
640
 
526
- /** The connection stubs for SQL / Supabase sources - the one manual wiring step. */
641
+ /** The Supabase client wiring. Reads the project URL + anon key from PUBLIC_ env vars
642
+ * (never inlined into source); the anon key is public / browser-safe, so this is a
643
+ * secret-hygiene win and matches SvelteKit convention. The designer-entered values are
644
+ * shown as a comment so they can be pasted straight into `.env` / `.env.example`. */
527
645
  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`
646
+ const hint = needs.supabaseUrl || needs.supabaseKey
647
+ ? `// From the Studio designer - paste into .env:\n// PUBLIC_SUPABASE_URL=${needs.supabaseUrl ?? ''}\n// PUBLIC_SUPABASE_ANON_KEY=${needs.supabaseKey ?? ''}\n`
648
+ : `// Set PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY in .env (see .env.example).\n`
543
649
  return {
544
650
  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`,
651
+ description: 'Supabase client for Supabase-bound entities (reads PUBLIC_SUPABASE_* env vars).',
652
+ contents: `import { createClient } from '@supabase/supabase-js'
653
+ import { env } from '$env/dynamic/public'
654
+ import type { SupabaseClientLike } from '@svgrid/enterprise'
655
+
656
+ /**
657
+ * Supabase client. The anon key is public (browser-safe) - access is protected by
658
+ * your Row Level Security policies. Requires \`@supabase/supabase-js\`.
659
+ */
660
+ ${hint}export const supabaseClient = createClient(
661
+ env.PUBLIC_SUPABASE_URL ?? '',
662
+ env.PUBLIC_SUPABASE_ANON_KEY ?? '',
663
+ ) as unknown as SupabaseClientLike
664
+ `,
547
665
  }
548
666
  }
549
667
 
@@ -580,7 +698,7 @@ export function entityScreenPage(schema: EntitySchema, route?: string, title?: s
580
698
 
581
699
  export type NavItem = { href: string; label: string; id?: string }
582
700
 
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 {
701
+ 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
702
  // Nav is the app's own screens; `/` just redirects to the first one, so no separate
585
703
  // "Home" link (it would duplicate the first screen).
586
704
  const links = nav
@@ -621,7 +739,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
621
739
 
622
740
  // i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
623
741
  const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
624
- 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>`
625
743
  // When RBAC is on, hide nav links the current role can't open.
626
744
  const linkGate = opts.access
627
745
  ? `{#if !item.id || canScreen($currentRole, item.id)}${anchor}{/if}`
@@ -736,7 +854,9 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
736
854
  <button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>`}
737
855
  ${opts.auth
738
856
  ? `<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>`}
857
+ : opts.supabaseAuth
858
+ ? '' // Supabase Auth: the real sign-out lives on SvAuthGate's own bar, so no placeholder here.
859
+ : `<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>`}
740
860
  </div>
741
861
  {/if}
742
862
  </div>
@@ -744,9 +864,17 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
744
864
  {#if bellOpen || menuOpen}<button type="button" class="sv-app__scrim" tabindex="-1" aria-label="Close menus" onclick={() => { bellOpen = false; menuOpen = false }}></button>{/if}
745
865
  </div>\n `
746
866
  : ''
867
+ // Supabase Auth: gate the whole app behind a client-side sign-in. SvAuthGate shows
868
+ // the sign-in form when signed out, and the app + a "signed in as … / Sign out" bar
869
+ // when signed in. Reads the shared client from $lib/connections.
870
+ const content = opts.supabaseAuth
871
+ ? `<SvAuthGate client={supabaseClient as unknown as SupabaseAuthClientLike} title={\`Sign in to \${brand}\`}>
872
+ {@render children()}
873
+ </SvAuthGate>`
874
+ : `{@render children()}`
747
875
  const mainMarkup = `<main class="sv-app__main">
748
876
  ${toolbarMarkup}<div class="sv-app__content">
749
- {@render children()}
877
+ ${content}
750
878
  </div>
751
879
  </main>`
752
880
 
@@ -842,14 +970,14 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
842
970
  contents: `<script lang="ts">
843
971
  import '../app.css'
844
972
  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'` : ''}
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'` : ''}
846
974
 
847
975
  let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
848
976
  const nav = ${JSON.stringify(links)}
849
977
  const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
850
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) })` : ''}
851
979
  // Close the mobile drawer whenever the route changes.
852
- $effect(() => { void $page.url.pathname; navOpen = false })${hasSwitch ? `
980
+ $effect(() => { void page.url.pathname; navOpen = false })${hasSwitch ? `
853
981
  // Light/dark switcher: defaults to the mode picked in Studio, then honours the
854
982
  // visitor's saved choice. Applies via [data-theme] on <html> (see the token
855
983
  // sets in <svelte:head>), and persists per browser.
@@ -888,11 +1016,11 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
888
1016
  return () => window.removeEventListener('keydown', onKey)
889
1017
  })
890
1018
  // Close both menus on route change.
891
- $effect(() => { void $page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
1019
+ $effect(() => { void page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
892
1020
  </script>
893
1021
  ${themeHead}
894
1022
 
895
- ${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)}
896
1024
  {@render children()}
897
1025
  {:else}
898
1026
  ${body}
@@ -1011,13 +1139,26 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
1011
1139
  /** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
1012
1140
  export function emitEntityModules(
1013
1141
  schemas: EntitySchema[],
1014
- opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers> } = {},
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 } = {},
1015
1143
  ): { files: GeneratedFile[]; prepared: EntitySchema[] } {
1016
1144
  const { entries, seed } = prepareEntities(schemas)
1017
- const { file: data, needs } = dataModule(entries, seed, opts.sources)
1145
+ const { file: data, needs } = dataModule(entries, seed, opts.sources, opts.supabaseConn)
1018
1146
  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] }))
1147
+ // Supabase Auth needs the shared client even with no Supabase-bound entity; seed
1148
+ // the .env paste hint from the project connection when nothing else did.
1149
+ if ((needs.supabase || opts.supabaseAuth) && !needs.supabaseUrl && (opts.supabaseConn?.url || opts.supabaseConn?.key)) {
1150
+ needs.supabaseUrl = opts.supabaseConn.url; needs.supabaseKey = opts.supabaseConn.key
1151
+ }
1152
+ if (needs.supabase || opts.supabaseAuth) files.push(connectionsModule(needs))
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
+ }))
1021
1162
  return { files, prepared: entries.map((e) => e.schema) }
1022
1163
  }
1023
1164
 
@@ -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,9 +40,37 @@ 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'
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'
45
74
  export {
46
75
  sanitizeStudioProject,
47
76
  buildStudioBugReport,
@@ -132,10 +161,23 @@ export {
132
161
  type TriggerStep,
133
162
  type TriggerEvent,
134
163
  type EntityTriggers,
164
+ setJob,
165
+ type ScheduledJob,
166
+ type ScheduledJobKind,
167
+ setTenancy,
168
+ tenantField,
169
+ isTenantScoped,
170
+ type TenancyConfig,
135
171
  clickSlot,
172
+ ssrScreenShape,
173
+ ssrEligible,
174
+ setScreenRenderMode,
175
+ eventSlot,
136
176
  rowSelectSlot,
137
177
  changeSlot,
138
178
  FORM_SUBMIT,
179
+ GRID_EVENTS,
180
+ type GridEventDef,
139
181
  addStateVar,
140
182
  updateStateVar,
141
183
  removeStateVar,
@@ -263,6 +305,7 @@ export {
263
305
  type EntityDataSource,
264
306
  type MemorySource,
265
307
  type RestSource,
308
+ type RestAdapterConfig,
266
309
  type SqlSource,
267
310
  type SupabaseSource,
268
311
  type PgliteSource,
@@ -288,10 +331,25 @@ export {
288
331
  export {
289
332
  UI_COMPONENT_REGISTRY,
290
333
  uiComponentSpec,
334
+ gridPropSurface,
335
+ gridApiSettableProps,
336
+ GRID_CURATED_PROPS,
337
+ STANDARD_UI_EVENTS,
291
338
  type UiPropType,
339
+ type UiPropGroup,
340
+ type UiComponentEvent,
292
341
  type UiComponentProp,
293
342
  type UiComponentSpec,
294
343
  } from './ui-components.js'
344
+ export { buildCopilotMessages, projectFromModelText, type CopilotMessages } from './copilot-core.js'
345
+ export {
346
+ resolveDeployTarget,
347
+ deployCommands,
348
+ missingEnvKeys,
349
+ type DeployProvider,
350
+ type DeployResolution,
351
+ type DeployPlanCommands,
352
+ } from './deploy-cli.js'
295
353
  export {
296
354
  runStudioAdd,
297
355
  runStudioAddApp,