@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +6885 -3504
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +5660 -1747
- package/package.json +8 -5
- package/src/SvGridBoard.svelte +4 -1
- package/src/SvGridScheduler.svelte +114 -90
- package/src/ai-export-pdf.dom.test.ts +77 -77
- package/src/ai-export-xlsx.dom.test.ts +90 -90
- package/src/ai-export.dom.test.ts +114 -114
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/expressions/evaluate.ts +9 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +1 -1
- package/src/index.ts +12 -0
- package/src/pivot.test.ts +0 -1
- package/src/schema-designer.ts +1 -1
- package/src/studio/emit-project.test.ts +298 -7
- package/src/studio/emit-project.ts +483 -29
- package/src/studio/emit-schema.ts +20 -10
- package/src/studio/index.ts +34 -0
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/project.test.ts +0 -1
- package/src/studio/project.ts +109 -2
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/live-data.test.ts +1 -1
- package/src/studio/samples/samples.test.ts +268 -268
- package/src/studio/samples/shared.ts +7 -150
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +2 -2
- package/src/studio/ui-components.generated.ts +1643 -106
- package/src/studio/ui-components.ts +742 -641
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.ts +1 -1
- package/src/sveltekit/transport-scope.test.ts +125 -0
- 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 {
|
|
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={
|
|
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(
|
|
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
|
|
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}${
|
|
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 =
|
|
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) +
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2706
|
-
//
|
|
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:
|
|
2726
|
-
*
|
|
2727
|
-
|
|
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().
|
|
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(' · ') + '</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
|
|
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
|
-
|
|
3938
|
-
|
|
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)! }] : []),
|