@svgrid/enterprise 2.3.0 → 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 (49) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +6885 -3504
  2. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  3. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  4. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  5. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  6. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  7. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  8. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  9. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  10. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  11. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  12. package/dist/designer/index.html +13 -0
  13. package/dist/node/studio.js +5660 -1747
  14. package/package.json +8 -5
  15. package/src/SvGridBoard.svelte +4 -1
  16. package/src/SvGridScheduler.svelte +114 -90
  17. package/src/ai-export-pdf.dom.test.ts +77 -77
  18. package/src/ai-export-xlsx.dom.test.ts +90 -90
  19. package/src/ai-export.dom.test.ts +114 -114
  20. package/src/export-ooxml.ts +4 -0
  21. package/src/export-xls.ts +3 -0
  22. package/src/expressions/evaluate.ts +9 -0
  23. package/src/import.test.ts +1 -1
  24. package/src/import.ts +1 -1
  25. package/src/index.ts +12 -0
  26. package/src/pivot.test.ts +0 -1
  27. package/src/schema-designer.ts +1 -1
  28. package/src/studio/emit-project.test.ts +298 -7
  29. package/src/studio/emit-project.ts +483 -29
  30. package/src/studio/emit-schema.ts +20 -10
  31. package/src/studio/index.ts +34 -0
  32. package/src/studio/init-flow.test.ts +239 -0
  33. package/src/studio/init-flow.ts +358 -0
  34. package/src/studio/project.test.ts +0 -1
  35. package/src/studio/project.ts +109 -2
  36. package/src/studio/samples/datasets.test.ts +84 -0
  37. package/src/studio/samples/datasets.ts +340 -0
  38. package/src/studio/samples/live-data.test.ts +1 -1
  39. package/src/studio/samples/samples.test.ts +268 -268
  40. package/src/studio/samples/shared.ts +7 -150
  41. package/src/studio/screen-suites.test.ts +226 -0
  42. package/src/studio/screen-suites.ts +445 -0
  43. package/src/studio/ui-components-surface.test.ts +2 -2
  44. package/src/studio/ui-components.generated.ts +1643 -106
  45. package/src/studio/ui-components.ts +742 -641
  46. package/src/sveltekit/index.ts +1 -0
  47. package/src/sveltekit/sql-source.ts +1 -1
  48. package/src/sveltekit/transport-scope.test.ts +125 -0
  49. package/src/sveltekit/transport.ts +76 -2
@@ -11,8 +11,9 @@
11
11
  * self-contained).
12
12
  */
13
13
  import type { GeneratedFile } from './scaffold.js'
14
- import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, SchedulerViewConfig, Screen, StudioProject, SupabaseSource } from './project.js'
15
- import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, eventSlot, FORM_SUBMIT, GRID_EVENTS, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY, isSsrScreen, ssrScreenShape } from './project.js'
14
+ import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, ScheduledJob, SchedulerViewConfig, Screen, StudioProject, SupabaseSource } from './project.js'
15
+ import { tenantField, isTenantScoped } from './project.js'
16
+ import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, rowSelectSlot, eventSlot, FORM_SUBMIT, GRID_EVENTS, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY, isSsrScreen, ssrScreenShape } from './project.js'
16
17
  import { uiComponentSpec, gridApiSettableProps, STANDARD_UI_EVENTS } from './ui-components.js'
17
18
  import { resolveThemeTokens, resolveThemeTokensFor, isDarkTheme } from './themes.js'
18
19
  import type { EntityField, EntitySchema } from '../schema.js'
@@ -503,7 +504,7 @@ ${panels}
503
504
  if (rels.length) props.push(`related={[${rels.join(', ')}]}`)
504
505
  // Open the record named by the URL `?id=` (set by a grid / board / calendar
505
506
  // drill-through); stays switchable via the header dropdown.
506
- props.push(`selectedId={$page.url.searchParams.get('id') ?? undefined}`)
507
+ props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`)
507
508
  if (h) props.push(`height={${h}}`)
508
509
  return ` <div ${wrapperStyle(block)}${cls}>
509
510
  <SvRecordDetail ${props.join(' ')} />
@@ -793,7 +794,14 @@ const cellSnippetName = (idSafe: string, field: string): string => `cellRender_$
793
794
  /** Does this grid want an export toolbar (any export affordance enabled)? */
794
795
  function gridHasExport(cfg: GridConfig): boolean {
795
796
  const e = cfg.export
796
- return !!e && (!!e.csv || !!e.json || !!e.copy)
797
+ return !!e && (!!e.csv || !!e.json || !!e.copy || !!e.xlsx || !!e.pdf || !!e.print)
798
+ }
799
+
800
+ /** True when a grid's export bar needs `@svgrid/enterprise` (xlsx / pdf / print)
801
+ * rather than only the free grid API (csv / json / copy). */
802
+ function gridHasEnterpriseExport(cfg: GridConfig): boolean {
803
+ const e = cfg.export
804
+ return !!e && (!!e.xlsx || !!e.pdf || !!e.print)
797
805
  }
798
806
 
799
807
  /** Is this a tree-data grid (self-referential hierarchy)? */
@@ -888,6 +896,12 @@ function exportToolbarMarkup(e: NonNullable<GridConfig['export']>, apiVar: strin
888
896
  const btns: string[] = []
889
897
  if (e.csv) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.exportCsv({ filename: ${fn} })}>Export CSV</button>`)
890
898
  if (e.json) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.exportJson({ filename: ${fn} })}>Export JSON</button>`)
899
+ // xlsx / pdf / print go through @svgrid/enterprise. `exportGrid` reads the
900
+ // grid's own visible columns and displayed rows, so the file matches what the
901
+ // user sees - no column list to keep in sync here.
902
+ if (e.xlsx) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stExport(${apiVar}, 'xlsx', ${fn})}>Export Excel</button>`)
903
+ if (e.pdf) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stExport(${apiVar}, 'pdf', ${fn})}>Export PDF</button>`)
904
+ if (e.print) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stPrint(${apiVar}, ${fn})}>Print</button>`)
891
905
  if (e.copy) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.copyToClipboard()}>Copy</button>`)
892
906
  return ` <div class="st-grid-toolbar">\n ${btns.join('\n ')}\n </div>\n`
893
907
  }
@@ -903,6 +917,31 @@ const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutra
903
917
  return 'neutral'
904
918
  }`
905
919
 
920
+ /** Shared helper: run an `@svgrid/enterprise` export off a captured grid API.
921
+ * Emitted once per page when any grid has an xlsx / pdf button. Surfaces the
922
+ * failure instead of swallowing it - a missing optional peer dep (jszip for
923
+ * xlsx, pdfmake for pdf) is the usual cause and is worth seeing. */
924
+ const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
925
+ if (!api) return
926
+ try {
927
+ await exportGrid(api, { format, filename })
928
+ } catch (err) {
929
+ console.error('Export failed:', err)
930
+ alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
931
+ }
932
+ }`
933
+
934
+ /** Shared helper: paginated print off a captured grid API. */
935
+ const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
936
+ if (!api) return
937
+ try {
938
+ await printGrid(api, { title })
939
+ } catch (err) {
940
+ console.error('Print failed:', err)
941
+ alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
942
+ }
943
+ }`
944
+
906
945
  /** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
907
946
  function cellRendererSnippet(idSafe: string, field: string, cellType: NonNullable<GridColumnConfig['cellType']>): string {
908
947
  const name = cellSnippetName(idSafe, field)
@@ -1523,7 +1562,7 @@ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | un
1523
1562
  if (dataset === 'settable' && datasetRowsVar) ctxParts.push(`data: { get rows() { return ${datasetRowsVar} }, setRows: (r) => (${datasetRowsVar} = r) }`)
1524
1563
  else if (dataset === 'reload') ctxParts.push('data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }')
1525
1564
  ctxParts.push('goto')
1526
- ctxParts.push('params: Object.fromEntries($page.url.searchParams)')
1565
+ ctxParts.push('params: Object.fromEntries(page.url.searchParams)')
1527
1566
  if (screen.state?.length) ctxParts.push(`state: { ${screen.state.map((v) => `get ${v.name}() { return ${v.name} }, set ${v.name}(x) { ${v.name} = x }`).join(', ')} }`)
1528
1567
  return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle, usesGridHandle }
1529
1568
  }
@@ -1686,7 +1725,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1686
1725
 
1687
1726
  const codeImport = hasCode ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
1688
1727
  const gotoImport = hasCode ? `import { goto } from '$app/navigation'\n ` : ''
1689
- const pageStoreImport = hasCode ? `import { page } from '$app/stores'\n ` : ''
1728
+ const pageStateImport = hasCode ? `import { page } from '$app/state'\n ` : ''
1690
1729
  const handleDecls = (wiring?.decls ?? []).join('\n ')
1691
1730
  // The Grid exposes its real SvGridApi (onApiReady) so code gets the full, typed
1692
1731
  // grid API - ctx.grid.exportCsv(), selectCells(), startEditing(), ... - not a stub.
@@ -1717,7 +1756,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1717
1756
  path: `src/routes/${screen.route}/+page.svelte`,
1718
1757
  description: `${screen.title} screen (freestanding, no bound entity).`,
1719
1758
  contents: `<script lang="ts">
1720
- ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStoreImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1759
+ ${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
1721
1760
  </script>
1722
1761
 
1723
1762
  <h1 class="st__title">${title}</h1>
@@ -1864,6 +1903,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1864
1903
  if (hasPivot) entImports.push('SvPivotDesigner')
1865
1904
  // The scheduler renderer (grid scheduler-view + calendar block) is registered app-wide (idempotent).
1866
1905
  if (usesScheduler) entImports.push('enableSchedulerView')
1906
+ // xlsx / pdf / print export buttons.
1907
+ const wantsEnterpriseExport = allBlocks.some((b) => b.config.kind === 'grid' && gridHasEnterpriseExport(b.config))
1908
+ const wantsPrint = allBlocks.some((b) => b.config.kind === 'grid' && !!b.config.export?.print)
1909
+ if (wantsEnterpriseExport) entImports.push('exportGrid')
1910
+ if (wantsPrint) entImports.push('printGrid')
1867
1911
  if (rtSupabase) entImports.push('createSupabaseRealtime', 'type SupabaseRealtimeClientLike')
1868
1912
  // Dedupe: record + form both want SvGridEditPanel.
1869
1913
  const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
@@ -1904,7 +1948,7 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1904
1948
  for (const a of screenActions) parts.push(actionHandlerScript(a))
1905
1949
  if (needsController) {
1906
1950
  const urlFilter = applyUrlFilters
1907
- ? `\n const sp = $page.url.searchParams
1951
+ ? `\n const sp = page.url.searchParams
1908
1952
  const _cols: Record<string, { operator: 'equals'; value: string }> = {}
1909
1953
  for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
1910
1954
  if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
@@ -1931,6 +1975,14 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1931
1975
  if (blocks.some((b) => b.config.kind === 'grid' && b.config.columns.some((c) => c.show && c.cellType?.kind === 'badge'))) {
1932
1976
  parts.push(BADGE_VARIANT_HELPER)
1933
1977
  }
1978
+ // Enterprise export/print helpers, emitted once per page beside the toolbar
1979
+ // buttons that call them.
1980
+ if (blocks.some((b) => b.config.kind === 'grid' && (!!b.config.export?.xlsx || !!b.config.export?.pdf))) {
1981
+ parts.push(EXPORT_HELPER)
1982
+ }
1983
+ if (blocks.some((b) => b.config.kind === 'grid' && !!b.config.export?.print)) {
1984
+ parts.push(PRINT_HELPER)
1985
+ }
1934
1986
  for (const b of blocks) {
1935
1987
  if (b.config.kind === 'grid') {
1936
1988
  const idSafe = b.id.replace(/-/g, '_')
@@ -2124,13 +2176,13 @@ ${body}
2124
2176
  const accessSpecs = [...(needsCurrentRole ? ['currentRole'] : []), ...(gatesUi ? ['can'] : []), ...(gatesActions ? ['canScreen'] : [])]
2125
2177
  const accessImport = accessSpecs.length ? `import { ${accessSpecs.join(', ')} } from '$lib/access'\n ` : ''
2126
2178
  const i18nImport = i18nEnabled ? `import { t, localizeCols } from '$lib/i18n'\n ` : ''
2127
- // Code-behind needs goto (ctx.goto) + the page store (ctx.params) even when no
2179
+ // Code-behind needs goto (ctx.goto) + page state (ctx.params) even when no
2128
2180
  // block otherwise navigates; and the handle runtime for its data/component handles.
2129
2181
  const gotoImport = usesGoto || codeEnabled ? `import { goto } from '$app/navigation'\n ` : ''
2130
2182
  const codeImport = codeEnabled ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
2131
2183
  const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : '', codeWire?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
2132
2184
  const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
2133
- const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/stores'\n ` : ''
2185
+ const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/state'\n ` : ''
2134
2186
  const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
2135
2187
  // Surface a failed data load (silent empty grid otherwise) with a retry.
2136
2188
  const errorBanner = needsController
@@ -2563,7 +2615,13 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2563
2615
  if (s.entity === undefined) continue // freestanding screen - gates no entity route
2564
2616
  screensByEntity.set(s.entity, [...(screensByEntity.get(s.entity) ?? []), s.id])
2565
2617
  }
2566
- const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers, supabaseConn: project.supabase, supabaseAuth: project.auth?.enabled === true && project.auth.provider === 'supabase' })
2618
+ // Multi-tenancy needs BOTH the session (to know the tenant) and the typed data
2619
+ // layer (so the column exists in the schema). Missing either, it degrades to
2620
+ // off rather than emitting a scope that reads a column nothing declares.
2621
+ const tenancyRequested = project.tenancy?.enabled === true
2622
+ const tenancyOn = tenancyRequested && authEnabled && project.dataLayer === 'drizzle' && Object.values(sources).some((s) => s.kind === 'sql')
2623
+ const tenantCol = tenancyOn ? tenantField(project) : undefined
2624
+ const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers, supabaseConn: project.supabase, supabaseAuth: project.auth?.enabled === true && project.auth.provider === 'supabase', tenantField: tenantCol, tenantScoped: (name) => isTenantScoped(project, name) })
2567
2625
  const byName = new Map(prepared.map((s) => [s.name, s]))
2568
2626
  // Raw (unprepared) entities keep their original field set - needed to derive
2569
2627
  // relation display-field names that match withRelationLabels (the prepared
@@ -2648,14 +2706,23 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2648
2706
  const authRegister = dbBackedAuth && project.auth?.register === true
2649
2707
  const authUserAdmin = dbBackedAuth && accessEnabled && project.auth?.userAdmin === true
2650
2708
  const authTwoFactor = dbBackedAuth && project.auth?.twoFactor === true
2651
- const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor) : []
2709
+ // The audit trail persists to a real table whenever the typed layer is active;
2710
+ // without it there is nowhere to put one, so the store stays in-memory.
2711
+ const auditPersisted = project.audit === true && dataLayerActive
2712
+ const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor, auditPersisted, tenantCol) : []
2652
2713
  // Without the Drizzle layer (or on MSSQL, which it can't cover), ship plain SQL the
2653
2714
  // user runs once against their database - otherwise the tables must pre-exist.
2654
2715
  const ddlFiles = sqlEntities.length > 0 && !dataLayerActive ? sqlDdlFiles(project.entities, sources) : []
2655
2716
  // The builtin cookie-session auth files (hooks.server, /login, session store, ...)
2656
2717
  // are skipped for the Supabase provider - SvAuthGate replaces them.
2657
- const authFileList = authEnabled && !supabaseAuth ? authFiles(project, dbBackedAuth, accessEnabled) : []
2658
- const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
2718
+ const authFileList = authEnabled && !supabaseAuth ? authFiles(project, dbBackedAuth, accessEnabled, tenantCol) : []
2719
+ const auditFiles = auditEnabled ? [auditModule(auditPersisted), auditRouteFile(), auditViewerPage()] : []
2720
+ // Scheduled jobs. `emailReal` gates the email kind the same way the auth flows
2721
+ // gate theirs - an email job without the email layer emits a warning slot
2722
+ // rather than an import that would not resolve.
2723
+ const jobList = (project.jobs ?? []).filter((j) => j.id && j.cron)
2724
+ const jobFileList = jobList.length ? jobsFiles(project, jobList, project.auth?.email === true) : []
2725
+ const tenantFileList = tenantCol ? [tenantModule(tenantCol)] : []
2659
2726
  // Routes that render bare (no shell) + skip the login guard.
2660
2727
  const publicAuthRoutes = authEnabled && !supabaseAuth ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
2661
2728
  let navExtras = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
@@ -2663,7 +2730,7 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2663
2730
  if (authUserAdmin) navExtras = [...navExtras, { href: '/users', label: 'Users', id: '__users__' }]
2664
2731
  const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
2665
2732
  const handleFiles = project.screens.some(screenHasCode) ? [handlesModuleFile()] : []
2666
- return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...ddlFiles, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...ssrHelpers, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled && !supabaseAuth, supabaseAuth, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
2733
+ return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...ddlFiles, ...auditFiles, ...jobFileList, ...tenantFileList, ...i18nFiles, ...actionRouteFiles, ...ssrHelpers, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled && !supabaseAuth, supabaseAuth, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
2667
2734
  }
2668
2735
 
2669
2736
  /** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
@@ -2692,7 +2759,14 @@ function i18nModule(project: StudioProject): GeneratedFile {
2692
2759
  const en = buildMessages(project)
2693
2760
  const localeUnion = locales.map((l) => JSON.stringify(l)).join(' | ')
2694
2761
  const seeded = JSON.stringify(en, null, 2).replace(/\n/g, '\n ')
2695
- const messagesEntries = locales.map((l) => ` ${JSON.stringify(l)}: ${l === def ? seeded : '{}'},`).join('\n')
2762
+ // Every locale is seeded with the SAME keys, not just the default. An empty
2763
+ // `{}` left the translator to discover the key names by reading the codegen;
2764
+ // seeding means the work is "translate these values in place". Values start as
2765
+ // the default-locale copy, so an untranslated app reads correctly rather than
2766
+ // falling back to raw keys.
2767
+ const messagesEntries = locales
2768
+ .map((l) => ` ${JSON.stringify(l)}: ${seeded},${l === def ? '' : ' // TODO: translate'}`)
2769
+ .join('\n')
2696
2770
  return {
2697
2771
  path: 'src/lib/i18n.ts',
2698
2772
  description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
@@ -2702,8 +2776,9 @@ export type Locale = ${localeUnion}
2702
2776
  export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
2703
2777
  export const currentLocale = writable<Locale>(${JSON.stringify(def)})
2704
2778
 
2705
- // The default locale is seeded from your schema + screen labels. Fill the other
2706
- // locales in with the same keys; missing keys fall back to the default.
2779
+ // Seeded from your schema + screen labels. Every locale starts with the same
2780
+ // keys and the default-locale text, so translating is editing values in place -
2781
+ // no key hunting. Anything you delete falls back to the default locale.
2707
2782
  const messages: Record<Locale, Record<string, string>> = {
2708
2783
  ${messagesEntries}
2709
2784
  }
@@ -2722,12 +2797,18 @@ export function localizeCols<T extends { field?: string | number; header?: strin
2722
2797
  }
2723
2798
  }
2724
2799
 
2725
- /** The audit store: an in-memory `ServerDataSource` of change records + a
2726
- * `recordAudit` writer. Swap the source for SQL/Supabase to persist the trail. */
2727
- function auditModule(): GeneratedFile {
2800
+ /** The audit store: the `AuditEntry` schema, a source, and the `recordAudit`
2801
+ * writer the API routes call.
2802
+ *
2803
+ * With the typed data layer active the trail is written to a real `audit_log`
2804
+ * table, so it survives a restart. Without one there is nowhere to put it and
2805
+ * it falls back to an in-memory source (fine for a demo, useless as an audit
2806
+ * trail - the emitted comment says so). */
2807
+ function auditModule(persisted = false): GeneratedFile {
2808
+ if (persisted) return auditModulePersisted()
2728
2809
  return {
2729
2810
  path: 'src/lib/audit.ts',
2730
- description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). Swap the source for a DB table to persist it.',
2811
+ description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). NOT persisted - enable the typed data layer to write to a real table.',
2731
2812
  contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
2732
2813
  import type { EntitySchema } from '@svgrid/enterprise'
2733
2814
 
@@ -2786,6 +2867,248 @@ export async function recordAudit(input: {
2786
2867
  }
2787
2868
  }
2788
2869
 
2870
+ /** The DB-backed audit store (typed data layer active): writes to `audit_log`
2871
+ * via Drizzle, so the trail survives a restart and records before/after values
2872
+ * rather than just which field names changed. */
2873
+ function auditModulePersisted(): GeneratedFile {
2874
+ return {
2875
+ path: 'src/lib/audit.ts',
2876
+ description: 'Audit trail: the AuditEntry schema, a Drizzle-backed source over audit_log, and recordAudit() with before/after snapshots.',
2877
+ contents: `import { count, desc } from 'drizzle-orm'
2878
+ import type { EntitySchema } from '@svgrid/enterprise'
2879
+ import { db } from '$lib/server/db'
2880
+ import { auditLog } from '$lib/server/db/schema'
2881
+
2882
+ export type AuditEntry = {
2883
+ id: string
2884
+ at: string
2885
+ actor: string
2886
+ entity: string
2887
+ action: 'create' | 'update' | 'delete'
2888
+ recordId: string
2889
+ summary: string
2890
+ /** JSON snapshot of the row before the change (update / delete). */
2891
+ before?: string | null
2892
+ /** JSON snapshot of the row after it (create / update). */
2893
+ after?: string | null
2894
+ }
2895
+
2896
+ export const auditSchema: EntitySchema<AuditEntry> = {
2897
+ name: 'audit',
2898
+ idField: 'id',
2899
+ fields: [
2900
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
2901
+ { field: 'at', type: 'datetime', label: 'When' },
2902
+ { field: 'actor', type: 'text', label: 'Actor' },
2903
+ { field: 'entity', type: 'text', label: 'Entity' },
2904
+ { field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
2905
+ { field: 'recordId', type: 'text', label: 'Record' },
2906
+ { field: 'summary', type: 'text', label: 'Summary' },
2907
+ { field: 'before', type: 'json', label: 'Before', readonly: true },
2908
+ { field: 'after', type: 'json', label: 'After', readonly: true },
2909
+ ],
2910
+ }
2911
+
2912
+ const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
2913
+ id: String(r.id),
2914
+ at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
2915
+ actor: r.actor,
2916
+ entity: r.entity,
2917
+ action: r.action as AuditEntry['action'],
2918
+ recordId: r.recordId,
2919
+ summary: r.summary,
2920
+ before: r.before ?? null,
2921
+ after: r.after ?? null,
2922
+ })
2923
+
2924
+ /** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
2925
+ * the database so a long trail doesn't load in one go. No write methods: the
2926
+ * trail is append-only through \\\`recordAudit\\\`. */
2927
+ export const auditSource = {
2928
+ async getRows(request: { startRow?: number; endRow?: number }) {
2929
+ const start = request.startRow ?? 0
2930
+ const limit = Math.max(1, (request.endRow ?? start + 25) - start)
2931
+ const [rows, counted] = await Promise.all([
2932
+ db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
2933
+ db.select({ n: count() }).from(auditLog),
2934
+ ])
2935
+ return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
2936
+ },
2937
+ }
2938
+
2939
+ /** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
2940
+ export async function recordAudit(input: {
2941
+ entity: string
2942
+ action: 'create' | 'update' | 'delete'
2943
+ recordId: string | null
2944
+ values?: Record<string, unknown>
2945
+ before?: Record<string, unknown> | null
2946
+ actor?: string
2947
+ }): Promise<void> {
2948
+ const summary =
2949
+ input.action === 'delete'
2950
+ ? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
2951
+ : \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
2952
+ await db.insert(auditLog).values({
2953
+ at: new Date().toISOString(),
2954
+ actor: input.actor ?? 'system',
2955
+ entity: input.entity,
2956
+ action: input.action,
2957
+ recordId: input.recordId ?? '',
2958
+ summary,
2959
+ before: input.before ? JSON.stringify(input.before) : null,
2960
+ after: input.values ? JSON.stringify(input.values) : null,
2961
+ } as typeof auditLog.$inferInsert)
2962
+ }
2963
+ `,
2964
+ }
2965
+ }
2966
+
2967
+ /**
2968
+ * The tenant resolver: reads the signed-in user's tenant off the session.
2969
+ *
2970
+ * `requireTenant` THROWS when there is no tenant, and the transport turns a
2971
+ * thrown scope resolver into a 403 - so an unauthenticated or tenant-less
2972
+ * request fails closed instead of silently querying every tenant's rows.
2973
+ */
2974
+ function tenantModule(field: string): GeneratedFile {
2975
+ return {
2976
+ path: 'src/lib/server/tenant.ts',
2977
+ description: 'Resolve the caller\'s tenant from the session; used to scope every API route.',
2978
+ contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
2979
+ //
2980
+ // The tenant is carried on the session (see hooks.server.ts / auth.ts) and
2981
+ // stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
2982
+
2983
+ export type TenantEvent = { locals?: Record<string, unknown> }
2984
+
2985
+ /** The caller's tenant id, or null when there is none. */
2986
+ export function getTenant(event: TenantEvent): string | null {
2987
+ const t = event.locals?.${field}
2988
+ return t == null || t === '' ? null : String(t)
2989
+ }
2990
+
2991
+ /**
2992
+ * The caller's tenant id, or THROW.
2993
+ *
2994
+ * Throwing is the point: the route's \`scope\` turns it into a 403. Returning
2995
+ * null here would let the query run unscoped, which is the one failure mode
2996
+ * multi-tenancy cannot have.
2997
+ */
2998
+ export function requireTenant(event: TenantEvent): string {
2999
+ const t = getTenant(event)
3000
+ if (!t) throw new Error('No tenant on the session - sign in again.')
3001
+ return t
3002
+ }
3003
+ `,
3004
+ }
3005
+ }
3006
+
3007
+ /**
3008
+ * Scheduled jobs: the handler registry + the guarded `/api/cron` route.
3009
+ *
3010
+ * Server-side, unlike `@svgrid/enterprise`'s `createScheduler`, which only ticks
3011
+ * while a browser tab is open. The platform's scheduler calls the route; the
3012
+ * route checks `CRON_SECRET` and runs the due handlers.
3013
+ */
3014
+ function jobsFiles(project: StudioProject, jobs: ScheduledJob[], emailAvailable: boolean): GeneratedFile[] {
3015
+ const handler = (j: ScheduledJob): string => {
3016
+ const label = jsStr(j.name)
3017
+ if (j.kind === 'email') {
3018
+ const ent = project.entities.find((e) => e.name === j.entity)
3019
+ const to = jsStr(j.to ?? '')
3020
+ const subject = jsStr(j.subject ?? j.name)
3021
+ if (!ent || !emailAvailable || !j.to) {
3022
+ // Emit the slot anyway so the schedule is real and the gap is obvious,
3023
+ // rather than silently dropping the job.
3024
+ const why = !emailAvailable ? 'email is not enabled on this project' : !j.to ? 'no recipient set' : `unknown entity ${j.entity}`
3025
+ return ` ${JSON.stringify(j.id)}: async () => {\n // ${label}: cannot send - ${why}.\n console.warn('cron ${j.id}: skipped (${why})')\n },`
3026
+ }
3027
+ const n = namesFor(ent)
3028
+ return ` ${JSON.stringify(j.id)}: async () => {
3029
+ const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
3030
+ const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' &middot; ') + '</li>').join('')
3031
+ await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
3032
+ },`
3033
+ }
3034
+ const body = (j.code ?? '').trim() || `console.log('cron ${j.id}: no body yet')`
3035
+ return ` ${JSON.stringify(j.id)}: async () => {\n${body.split('\n').map((l) => ' ' + l).join('\n')}\n },`
3036
+ }
3037
+
3038
+ const entityImports = new Set<string>()
3039
+ for (const j of jobs) {
3040
+ if (j.kind !== 'email') continue
3041
+ const ent = project.entities.find((e) => e.name === j.entity)
3042
+ if (ent && emailAvailable && j.to) entityImports.add(namesFor(ent).sourceVar)
3043
+ }
3044
+ const imports = [
3045
+ ...(entityImports.size ? [`import { ${[...entityImports].sort().join(', ')} } from '$lib/data'`] : []),
3046
+ ...(jobs.some((j) => j.kind === 'email') && emailAvailable ? [`import { sendEmail } from '$lib/server/email'`] : []),
3047
+ ]
3048
+
3049
+ const table = jobs.map((j) => ` * ${j.id.padEnd(20)} ${j.cron.padEnd(16)} ${j.name}`).join('\n')
3050
+ const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
3051
+ //
3052
+ // Schedule (UTC):
3053
+ ${table}
3054
+ //
3055
+ // Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
3056
+ // handler body freely; the registry keys are what /api/cron dispatches on.
3057
+ ${imports.join('\n')}
3058
+
3059
+ export const jobs: Record<string, () => Promise<void>> = {
3060
+ ${jobs.map(handler).join('\n')}
3061
+ }
3062
+
3063
+ /** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
3064
+ export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
3065
+ `
3066
+
3067
+ const routeTs = `import { json, error } from '@sveltejs/kit'
3068
+ import { env } from '$env/dynamic/private'
3069
+ import type { RequestHandler } from './$types'
3070
+ import { jobs, enabledJobs } from '$lib/server/jobs'
3071
+
3072
+ /**
3073
+ * Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
3074
+ * GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
3075
+ *
3076
+ * Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
3077
+ * \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
3078
+ * than leaving a public "do work" URL open.
3079
+ */
3080
+ const authorized = (request: Request, url: URL): boolean => {
3081
+ const secret = env.CRON_SECRET
3082
+ if (!secret) return false
3083
+ const header = request.headers.get('authorization')
3084
+ return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
3085
+ }
3086
+
3087
+ const run: RequestHandler = async ({ request, url }) => {
3088
+ if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
3089
+ const only = url.searchParams.get('job')
3090
+ const ids = only ? [only] : enabledJobs
3091
+ const results: Array<{ job: string; ok: boolean; error?: string }> = []
3092
+ for (const id of ids) {
3093
+ const fn = jobs[id]
3094
+ if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
3095
+ // One failing job must not stop the rest of the run.
3096
+ try { await fn(); results.push({ job: id, ok: true }) }
3097
+ catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
3098
+ }
3099
+ return json({ ran: results.length, results })
3100
+ }
3101
+
3102
+ export const GET = run
3103
+ export const POST = run
3104
+ `
3105
+
3106
+ return [
3107
+ { path: 'src/lib/server/jobs.ts', description: 'Scheduled job handlers, keyed by job id.', contents: jobsTs },
3108
+ { path: 'src/routes/api/cron/+server.ts', description: 'Guarded cron endpoint that runs the scheduled jobs.', contents: routeTs },
3109
+ ]
3110
+ }
3111
+
2789
3112
  /** The read API for the audit trail (the /audit viewer reads it via the transport). */
2790
3113
  function auditRouteFile(): GeneratedFile {
2791
3114
  return {
@@ -2908,7 +3231,7 @@ export function authorizeAction(role: AppRole, action: 'read' | WriteAction, scr
2908
3231
  * `hooks.server.ts` that resolves the caller into `event.locals.role`/`user` (the loop
2909
3232
  * the RBAC layer already expects), a `/login` page + sign-out, and the `App.Locals`
2910
3233
  * type augmentation. Works across every data source (no DB required for the demo). */
2911
- function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false): GeneratedFile[] {
3234
+ function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false, tenantColumn?: string): GeneratedFile[] {
2912
3235
  const users = seedUsers(project)
2913
3236
  const demo = users[0]!
2914
3237
  const protect = project.auth?.protect !== false
@@ -3172,7 +3495,10 @@ import { SESSION_COOKIE, readSession } from '$lib/server/auth'
3172
3495
  export const handle: Handle = async ({ event, resolve }) => {
3173
3496
  const user = await readSession(event.cookies.get(SESSION_COOKIE))
3174
3497
  event.locals.user = user ?? undefined
3175
- event.locals.role = user?.role
3498
+ event.locals.role = user?.role${tenantColumn ? `
3499
+ // Multi-tenancy: the tenant travels on the session, so every scoped API route
3500
+ // reads it from here rather than trusting anything the client sends.
3501
+ event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
3176
3502
  return resolve(event)
3177
3503
  }
3178
3504
  `
@@ -3196,7 +3522,7 @@ export {}
3196
3522
  const layoutServerTs = `import type { LayoutServerLoad } from './$types'
3197
3523
  ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
3198
3524
  // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
3199
- // or \`$page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3525
+ // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3200
3526
  export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
3201
3527
  ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
3202
3528
  }
@@ -3934,8 +4260,44 @@ function dzColumn(dialect: DzDialect, field: EntityField, colName: string, isPk:
3934
4260
  /** The Drizzle `auth_users` table (added when auth + data layer are both on): the
3935
4261
  * DB-backed user store the login flow reads. Fixed shape: id, unique email, name,
3936
4262
  * role, passwordHash. Returns the table block + the core imports it needs. */
3937
- function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false): { block: string; imports: string[] } {
3938
- const tail = '\n})\nexport type AuthUserRow = typeof authUsers.$inferSelect\nexport type AuthUserNew = typeof authUsers.$inferInsert'
4263
+ /** The tenant-scoping column, per dialect. Indexed-by-convention (callers filter
4264
+ * on it in every query), not null - a row with no tenant belongs to nobody and
4265
+ * would be invisible to every scoped read. */
4266
+ function dzTenantColumn(dialect: DzDialect, field: string): { expr: string; imports: string[] } {
4267
+ const col = field.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
4268
+ if (dialect === 'mysql') return { expr: `varchar(${JSON.stringify(col)}, { length: 128 }).notNull()`, imports: ['varchar'] }
4269
+ return { expr: `text(${JSON.stringify(col)}).notNull()`, imports: ['text'] }
4270
+ }
4271
+
4272
+ /** The audit-trail table, in the same schema as the entities so one migration
4273
+ * covers it. Shaped like `AuditEntry` in `$lib/audit`, plus `before` / `after`
4274
+ * JSON snapshots that the in-memory store never kept. */
4275
+ function dzAuditTable(dialect: DzDialect, tableFn: string): { block: string; imports: string[] } {
4276
+ const tail = '\n})\nexport type AuditRow = typeof auditLog.$inferSelect\nexport type AuditNew = typeof auditLog.$inferInsert'
4277
+ if (dialect === 'postgres') {
4278
+ return {
4279
+ imports: [tableFn, 'serial', 'text', 'timestamp'],
4280
+ block: `export const auditLog = ${tableFn}("audit_log", {\n "id": serial("id").primaryKey(),\n "at": timestamp("at").notNull().defaultNow(),\n "actor": text("actor").notNull(),\n "entity": text("entity").notNull(),\n "action": text("action").notNull(),\n "recordId": text("record_id").notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
4281
+ }
4282
+ }
4283
+ if (dialect === 'mysql') {
4284
+ return {
4285
+ imports: [tableFn, 'int', 'varchar', 'text', 'timestamp'],
4286
+ block: `export const auditLog = ${tableFn}("audit_log", {\n "id": int("id").autoincrement().primaryKey(),\n "at": timestamp("at").notNull().defaultNow(),\n "actor": varchar("actor", { length: 255 }).notNull(),\n "entity": varchar("entity", { length: 128 }).notNull(),\n "action": varchar("action", { length: 16 }).notNull(),\n "recordId": varchar("record_id", { length: 128 }).notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
4287
+ }
4288
+ }
4289
+ // sqlite / turso
4290
+ return {
4291
+ imports: [tableFn, 'integer', 'text'],
4292
+ block: `export const auditLog = ${tableFn}("audit_log", {\n "id": integer("id").primaryKey({ autoIncrement: true }),\n "at": text("at").notNull(),\n "actor": text("actor").notNull(),\n "entity": text("entity").notNull(),\n "action": text("action").notNull(),\n "recordId": text("record_id").notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
4293
+ }
4294
+ }
4295
+
4296
+ function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false, tenantColumn?: string): { block: string; imports: string[] } {
4297
+ // Multi-tenancy: the user's own tenant is where every scoped request gets its
4298
+ // tenant from, so the user store has to carry it.
4299
+ const ten = tenantColumn ? `\n ${JSON.stringify(tenantColumn)}: ${dzTenantColumn(dialect, tenantColumn).expr},` : ''
4300
+ const tail = `${ten}\n})\nexport type AuthUserRow = typeof authUsers.$inferSelect\nexport type AuthUserNew = typeof authUsers.$inferInsert`
3939
4301
  if (dialect === 'postgres') {
3940
4302
  const tfa = with2fa ? '\n "twoFactor": boolean("two_factor").notNull().default(false),' : ''
3941
4303
  return {
@@ -3962,7 +4324,7 @@ function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false): { b
3962
4324
  * there's a SQL-bound entity on a supported dialect): a schema (the source of truth
3963
4325
  * for drizzle-kit migrations), a client, a typed repository per entity, and the
3964
4326
  * drizzle.config.ts. The connected `+server.ts` routes read the same tables. */
3965
- function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sources: Record<string, EntityDataSource>, includeUsers = false, usersTwoFactor = false): GeneratedFile[] {
4327
+ function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sources: Record<string, EntityDataSource>, includeUsers = false, usersTwoFactor = false, includeAudit = false, tenantColumn?: string): GeneratedFile[] {
3966
4328
  const firstSql = sources[sqlEntities[0]!.name]
3967
4329
  const dialect = dzDialect(firstSql?.kind === 'sql' ? firstSql.dialect : undefined)
3968
4330
  if (!dialect) return [] // MSSQL: raw route only (Drizzle has no SQL Server driver)
@@ -3985,15 +4347,29 @@ function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sou
3985
4347
  c.imports.forEach((i) => colImports.add(i))
3986
4348
  return ` ${JSON.stringify(f.field)}: ${c.expr},`
3987
4349
  })
4350
+ // Multi-tenancy: the scoping column the API route filters/stamps on. Added
4351
+ // here (not to the entity's field list) so it stays out of forms and grids -
4352
+ // it is infrastructure, not data the user edits.
4353
+ if (tenantColumn && isTenantScoped(project, e.name) && !e.fields.some((f) => f.field === tenantColumn)) {
4354
+ const t = dzTenantColumn(dialect, tenantColumn)
4355
+ t.imports.forEach((i) => colImports.add(i))
4356
+ cols.push(` ${JSON.stringify(tenantColumn)}: ${t.expr},`)
4357
+ }
3988
4358
  tableBlocks.push(`export const ${tableVar} = ${cfg.table}(${JSON.stringify(table)}, {\n${cols.join('\n')}\n})\nexport type ${type}Row = typeof ${tableVar}.$inferSelect\nexport type ${type}New = typeof ${tableVar}.$inferInsert`)
3989
4359
  meta.push({ e, tableVar, pkKey: pk, pkNumber })
3990
4360
  }
3991
4361
  // Auth: the DB-backed user store lives in the same schema (so one migration covers it).
3992
4362
  if (includeUsers) {
3993
- const u = dzUsersTable(dialect, cfg.table, usersTwoFactor)
4363
+ const u = dzUsersTable(dialect, cfg.table, usersTwoFactor, tenantColumn)
3994
4364
  u.imports.forEach((i) => colImports.add(i))
3995
4365
  tableBlocks.push(u.block)
3996
4366
  }
4367
+ // Audit: same reasoning - one migration covers the trail alongside the data.
4368
+ if (includeAudit) {
4369
+ const a = dzAuditTable(dialect, cfg.table)
4370
+ a.imports.forEach((i) => colImports.add(i))
4371
+ tableBlocks.push(a.block)
4372
+ }
3997
4373
  const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
3998
4374
  // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
3999
4375
  import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
@@ -4487,6 +4863,12 @@ function envExample(allSource: string): string | null {
4487
4863
  if (allSource.includes("'google'") || allSource.includes('"google"')) { lines.push('# GOOGLE_CLIENT_ID='); lines.push('# GOOGLE_CLIENT_SECRET=') }
4488
4864
  if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) { lines.push('# OIDC_ISSUER= # e.g. https://login.microsoftonline.com/<tenant>/v2.0'); lines.push('# OIDC_CLIENT_ID='); lines.push('# OIDC_CLIENT_SECRET=') }
4489
4865
  }
4866
+ if (allSource.includes('env.CRON_SECRET')) {
4867
+ lines.push('')
4868
+ lines.push('# Shared secret for /api/cron. The endpoint refuses to run without it,')
4869
+ lines.push('# so set the same value here and in your scheduler.')
4870
+ lines.push('CRON_SECRET=')
4871
+ }
4490
4872
  if (lines.length === 0) return null
4491
4873
  lines.push('')
4492
4874
  lines.push('# Optional: your SvGrid license key removes the unlicensed watermark.')
@@ -4514,6 +4896,17 @@ export function runtimeDeps(project: StudioProject, allSource: string): Record<s
4514
4896
  if (/from ['"]hyperformula['"]/.test(allSource)) dependencies['hyperformula'] = '^3.3.0'
4515
4897
  if (/from ['"]jszip['"]/.test(allSource)) dependencies['jszip'] = '^3.10.1'
4516
4898
  if (/from ['"]pdfmake(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['pdfmake'] = '^0.2.10'
4899
+ // xlsx / pdf export buttons reach these through @svgrid/enterprise, which
4900
+ // imports them lazily - so they never appear in the generated source and the
4901
+ // scans above cannot see them. Key off the export config instead, or the app
4902
+ // ships an Export Excel button that throws on the missing peer dep.
4903
+ for (const screen of project.screens ?? []) {
4904
+ for (const block of screen.blocks ?? []) {
4905
+ if (block.config.kind !== 'grid') continue
4906
+ if (block.config.export?.xlsx) dependencies['jszip'] = '^3.10.1'
4907
+ if (block.config.export?.pdf) dependencies['pdfmake'] = '^0.2.10'
4908
+ }
4909
+ }
4517
4910
  // nodemailer is dynamically imported by the email layer only on the SMTP branch.
4518
4911
  if (/import\(['"]nodemailer['"]\)/.test(allSource)) dependencies['nodemailer'] = '^6.9.0'
4519
4912
  if (project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['drizzle-orm'] = '^0.44.0'
@@ -4620,6 +5013,66 @@ export default defineConfig({
4620
5013
  * Download it, `npm install`, `npm run dev`. This is what the designer's
4621
5014
  * "Download .zip" produces.
4622
5015
  */
5016
+ /**
5017
+ * The platform-side half of scheduled jobs: something has to CALL `/api/cron`.
5018
+ *
5019
+ * Vercel has native cron, so its schedule goes in `vercel.json` and needs no
5020
+ * secrets. Every other target gets a GitHub Actions schedule that curls the
5021
+ * endpoint - it works anywhere the app is reachable, and stays inert until
5022
+ * `CRON_URL` / `CRON_SECRET` are set, so CI is green before you configure it.
5023
+ */
5024
+ function cronScheduleFiles(project: StudioProject, plan: DeployPlan): GeneratedFile[] {
5025
+ const jobs = (project.jobs ?? []).filter((j) => j.id && j.cron && j.enabled !== false)
5026
+ if (!jobs.length) return []
5027
+
5028
+ if (plan.label === 'Vercel') {
5029
+ // Vercel Cron hits the path on its own schedule; one entry per job so each
5030
+ // keeps its own cron expression.
5031
+ const crons = jobs.map((j) => ({ path: `/api/cron?job=${encodeURIComponent(j.id)}`, schedule: j.cron }))
5032
+ return [{
5033
+ path: 'vercel.json',
5034
+ description: 'Vercel Cron schedule for the app\'s background jobs.',
5035
+ contents: JSON.stringify({ crons }, null, 2) + '\n',
5036
+ }]
5037
+ }
5038
+
5039
+ const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
5040
+ if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
5041
+ run: |
5042
+ curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
5043
+ -H "authorization: Bearer $CRON_SECRET"
5044
+ `).join('')
5045
+ const schedules = [...new Set(jobs.map((j) => j.cron))].map((c) => ` - cron: '${c}'`).join('\n')
5046
+
5047
+ return [{
5048
+ path: '.github/workflows/cron.yml',
5049
+ description: 'Scheduled job runner: calls /api/cron on the deployed app.',
5050
+ contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
5051
+ # below and it calls the app's /api/cron endpoint.
5052
+ #
5053
+ # Set two repository secrets before it does anything:
5054
+ # CRON_URL https://<your-app>/api/cron
5055
+ # CRON_SECRET the same value as the app's CRON_SECRET env var
5056
+ # Until then the job short-circuits, so CI stays green.
5057
+ name: Scheduled jobs
5058
+
5059
+ on:
5060
+ schedule:
5061
+ ${schedules}
5062
+ workflow_dispatch:
5063
+
5064
+ jobs:
5065
+ cron:
5066
+ runs-on: ubuntu-latest
5067
+ if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
5068
+ env:
5069
+ CRON_URL: \${{ secrets.CRON_URL }}
5070
+ CRON_SECRET: \${{ secrets.CRON_SECRET }}
5071
+ steps:
5072
+ ${steps}`,
5073
+ }]
5074
+ }
5075
+
4623
5076
  export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
4624
5077
  const generated = emitStudioProject(project)
4625
5078
  const allSource = generated.map((f) => f.contents).join('\n')
@@ -4634,6 +5087,7 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
4634
5087
  { path: 'vitest.config.ts', description: 'Test runner config for the generated smoke tests (npm test).', contents: VITEST_CONFIG },
4635
5088
  { path: 'src/lib/schemas.test.ts', description: 'Smoke tests: every entity renders + round-trips through its data source.', contents: smokeTestFile(project) },
4636
5089
  ...plan.files,
5090
+ ...cronScheduleFiles(project, plan),
4637
5091
  ...SCAFFOLD_STATIC,
4638
5092
  ...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
4639
5093
  ...(envDotFile(allSource) ? [{ path: '.env', description: 'Local env (git-ignored): a real random SESSION_SECRET is pre-filled so sessions are secure out of the box; fill in the rest.', contents: envDotFile(allSource)! }] : []),