@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.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +13 -1
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- 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(
|
|
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
|
-
//
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
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:
|
|
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
|
-
:
|
|
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
|
-
{
|
|
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
|
-
|
|
1020
|
-
|
|
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
|
|
package/src/studio/index.ts
CHANGED
|
@@ -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
|
+
})
|