@svgrid/enterprise 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -11,12 +11,13 @@
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 } from './project.js'
15
- import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY } from './project.js'
16
- import { uiComponentSpec } from './ui-components.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'
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'
19
- import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
20
+ import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, prepareEntities, relationDisplayFields, sqlDdlFiles, type NavItem } from './emit-schema.js'
20
21
 
21
22
  const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
22
23
 
@@ -148,7 +149,7 @@ function screenLayoutStyle(screen: Screen): string {
148
149
 
149
150
  /** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
150
151
  * publish its clicked row into `selectedRecord` for a sibling record panel. */
151
- function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string } = { hasRecord: false }): string {
152
+ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string; gridEventSink?: string } = { hasRecord: false }): string {
152
153
  // A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
153
154
  const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
154
155
  // In a dock pane the block fills its pane (the pane owns the size); in the 12-col grid it
@@ -187,6 +188,23 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
187
188
  const tree = gridHasTree(cfg)
188
189
  const grouped = !!cfg.grouping?.length && !tree
189
190
  const dataExpr = tree ? `tree_${idSafe}.visible` : grouped ? 'allRows' : 'view.rows'
191
+ // Code-behind can subscribe to grid events (`ctx.grid.onCellClick = fn`): each
192
+ // event prop fires into the grid handle. Feature-owned props (sort/filter/edit/
193
+ // paginate/row-click) COMPOSE the fire onto their built-in body; the rest are
194
+ // added in one pass below. `sink` is the grid handle var, '' when code is off.
195
+ const sink = ctx.gridEventSink ?? ''
196
+ const firedProps = new Set<string>()
197
+ const fireInto = (key: string, args: string): string => {
198
+ if (!sink) return ''
199
+ firedProps.add(GRID_EVENTS.find((e) => e.key === key)!.prop)
200
+ return `; ${sink}.fire('${key}', ${args})`
201
+ }
202
+ // Compose a built-in event handler with the user's ctx.grid subscription. Stays
203
+ // terse (no braces, no fire) when code is off, so non-code screens are unchanged.
204
+ const withFire = (param: string, base: string, key: string, fireArgs: string): string => {
205
+ const fire = fireInto(key, fireArgs)
206
+ return fire ? `(${param}) => { ${base}${fire} }` : `(${param}) => ${base}`
207
+ }
190
208
  const lines = [`data={${dataExpr}}`, `columns={${colVar}}`, `loading={${tree || grouped ? '!allRowsReady' : 'view.loading'}}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
191
209
  lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
192
210
  if (cfg.striped) lines.push(`zebraRows`)
@@ -210,14 +228,19 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
210
228
  if (cfg.sortable) lines.push(`sortable`)
211
229
  if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg))
212
230
  } else {
213
- if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s) => controller.setSort(s)}`)
214
- if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={(f) => controller.setFilter({ global: f.global || undefined, columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])) })}`)
231
+ if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={${withFire('s', 'controller.setSort(s)', 'sortingChange', 's')}}`)
232
+ if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={${withFire('f', `controller.setFilter({ global: f.global || undefined, columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])) })`, 'filtersChange', 'f')}}`)
215
233
  }
216
234
  // RBAC: gate the edit affordances on the update permission (server also enforces).
217
235
  const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
218
- if (cfg.editing === 'form') lines.push(ctx.accessEnabled ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
236
+ if (cfg.editing === 'form') {
237
+ const fire = fireInto('rowDoubleClick', 'e')
238
+ lines.push(ctx.accessEnabled
239
+ ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row${fire} }}`
240
+ : fire ? `onRowDoubleClick={(e) => { (editing = e.row)${fire} }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
241
+ }
219
242
  // Inline editing writes through the controller by row id (not offered for tree grids).
220
- if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>) }}`)
243
+ if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>)${fireInto('cellValueChange', 'e')} }}`)
221
244
  // Row click: drill-through (highest precedence), a record-panel selection,
222
245
  // and/or the user's "On row select" method steps (row-scoped ctx). When steps
223
246
  // are present they merge with the built-in action into one async handler.
@@ -229,23 +252,30 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
229
252
  if (ctx.rowSelectSteps) {
230
253
  const pre = primaryStmt ? `\n ${primaryStmt}` : ''
231
254
  const stepBody = ctx.rowSelectSteps.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
232
- lines.push(`onRowClick={async (e) => {${pre}\n const row = e.row\n const ctx = ${ctx.ctxLiteral} as unknown as PageContext\n${stepBody}\n }}`)
255
+ const fire = fireInto('rowClick', 'e')
256
+ lines.push(`onRowClick={async (e) => {${pre}\n const row = e.row\n const ctx = ${ctx.ctxLiteral} as unknown as PageContext\n${stepBody}${fire ? '\n ' + fire.slice(2) : ''}\n }}`)
233
257
  } else if (rowLinkStmt) {
234
- lines.push(`onRowClick={(e) => ${rowLinkStmt}}`)
258
+ lines.push(`onRowClick={${withFire('e', rowLinkStmt, 'rowClick', 'e')}}`)
235
259
  } else if (ctx.hasRecord) {
236
- lines.push(`onRowClick={(e) => (selectedRecord = e.row)}`)
260
+ lines.push(`onRowClick={${withFire('e', '(selectedRecord = e.row)', 'rowClick', 'e')}}`)
237
261
  }
238
262
  if (cfg.paginated !== false && !tree) {
239
263
  if (grouped) {
240
264
  // Client pagination: the grid slices `data` itself.
241
265
  lines.push(`showPagination`, `pageSize={${cfg.pageSize}}`)
242
266
  } else {
243
- lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}`)
267
+ lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={${withFire('{ pageIndex, pageSize }', '(pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))', 'paginationChange', '{ pageIndex, pageSize }')}}`)
244
268
  }
245
269
  if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
246
270
  const opts = cfg.pageSizeOptions
247
271
  if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
248
272
  }
273
+ // Every remaining grid event -> fire into ctx.grid so code can subscribe
274
+ // (`ctx.grid.onCellClick = fn`). Feature-owned props already composed above.
275
+ if (sink) for (const ev of GRID_EVENTS) {
276
+ if (ev.dataEvent) continue // fired by the data controller, not a grid prop
277
+ if (!firedProps.has(ev.prop)) lines.push(`${ev.prop}={(...a) => ${sink}.fire('${ev.key}', ...a)}`)
278
+ }
249
279
  // No-code conditional formatting -> the grid's rule engine.
250
280
  const cf = conditionalFormatsExpr(cfg)
251
281
  if (cf) lines.push(`conditionalFormats={${cf}}`)
@@ -256,6 +286,15 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
256
286
  if (apiBody.length === 1 && ctx.captureApi && !grouped) lines.push(`onApiReady={(a) => (${ctx.captureApi} = a)}`)
257
287
  else if (apiBody.length) lines.push(`onApiReady={(a) => { ${apiBody.join('; ')} }}`)
258
288
  lines.push(`containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 360}}`}`)
289
+ // Raw "All properties" overrides: pass through every grid prop the curated
290
+ // controls didn't already emit (deduped by prop name), so nothing is set twice.
291
+ if (cfg.props && Object.keys(cfg.props).length) {
292
+ const emitted = new Set(lines.map((l) => l.split(/[=\s]/)[0]))
293
+ for (const [k, v] of Object.entries(cfg.props)) {
294
+ if (v === undefined || emitted.has(k)) continue
295
+ lines.push(v === true ? k : `${k}={${JSON.stringify(v)}}`)
296
+ }
297
+ }
259
298
  // No-code export toolbar - buttons wired to the grid's own export API.
260
299
  const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export!, ctx.captureApi, entity.name) : ''
261
300
  return ` <div ${span}${cls}>
@@ -296,10 +335,13 @@ ${exportBar} <SvGrid
296
335
  if (cfg.trendField) {
297
336
  const tReduce = cfg.trendReduce ?? cfg.reduce
298
337
  const seriesExpr = `kpiSeries(${rowsExpr}, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
338
+ // The first-to-last delta chip - only when there's no target chip already
339
+ // (avoids a dead `&& false` branch that left `_d` null-unsafe under svelte-check).
340
+ const deltaChip = cfg.target == null
341
+ ? `\n {@const _d = seriesDelta(_s)}\n {#if _d != null}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}`
342
+ : ''
299
343
  rows.push(` {#if ${seriesExpr}.length > 1}
300
- {@const _s = ${seriesExpr}}
301
- {@const _d = seriesDelta(_s)}
302
- {#if _d != null && ${cfg.target == null}}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}
344
+ {@const _s = ${seriesExpr}}${deltaChip}
303
345
  <svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
304
346
  {/if}`)
305
347
  }
@@ -462,7 +504,7 @@ ${panels}
462
504
  if (rels.length) props.push(`related={[${rels.join(', ')}]}`)
463
505
  // Open the record named by the URL `?id=` (set by a grid / board / calendar
464
506
  // drill-through); stays switchable via the header dropdown.
465
- props.push(`selectedId={$page.url.searchParams.get('id') ?? undefined}`)
507
+ props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`)
466
508
  if (h) props.push(`height={${h}}`)
467
509
  return ` <div ${wrapperStyle(block)}${cls}>
468
510
  <SvRecordDetail ${props.join(' ')} />
@@ -505,13 +547,23 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
505
547
  if (!spec) return ` <div ${span}${cls}><!-- unknown component "${cfg.component}" --></div>`
506
548
  if (handleName) {
507
549
  // Handle mode (code page): props + content come from the reactive handle, and
508
- // clicks fire on it - so button1.setVariant(...) / button1.onclick = fn work.
550
+ // events fire on it - so button1.setVariant(...) / button1.onclick = fn work.
509
551
  const inner = spec.hasContent ? `>{${handleName}.text}</${spec.importName}>` : ' />'
510
- // `change` bubbles up from any inner input, so the wrapper catches it too - that
511
- // powers the "On change" method slot without knowing the component's shape.
552
+ // Events with a component callback prop are wired THROUGH that prop (exact
553
+ // semantics, works for non-bubbling callbacks like a picker's onChange); the
554
+ // wrapper's DOM listeners remain only as the catch-all for click/change on
555
+ // components without a matching callback prop - and are skipped when a prop
556
+ // covers the same key, so an event never fires twice.
557
+ const propWired = (spec.events ?? []).filter((e) => e.prop)
558
+ const eventAttrs = propWired.map((e) => ` ${e.prop}={(e) => ${handleName}.fire(${jsStr(e.key)}, e)}`).join('')
559
+ // The wrapper forwards the full standard DOM event surface to the handle (VS-style
560
+ // events), skipping any a component callback prop already covers (no double-fire).
561
+ const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
562
+ .map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
563
+ .join('')
512
564
  return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
513
- <div id="${block.id}" onclick={(e) => ${handleName}.fire('click', e)} onchange={(e) => ${handleName}.fire('change', e)} ${span}${cls}>
514
- <${spec.importName} {...${handleName}.props}${inner}
565
+ <div id="${block.id}"${wrapper} ${span}${cls}>
566
+ <${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
515
567
  </div>`
516
568
  }
517
569
  // Data bindings: a bound prop's value is a reactive expression over the screen's
@@ -520,13 +572,13 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
520
572
  const bound = (key: string): ComponentBinding | undefined => (rowsExpr ? bindings[key] : undefined)
521
573
  const attrs: string[] = []
522
574
  for (const p of spec.props) {
575
+ if (p.code) continue // code-only props (functions) are set via the handle, not literals
523
576
  const b = bound(p.key)
524
577
  if (b) { attrs.push(`${p.key}={${bindingExpr(b, rowsExpr!, p.type === 'number')}}`); continue }
525
578
  const v = cfg.props[p.key] ?? p.default
526
579
  if (v == null || v === '') continue
527
580
  if (p.type === 'boolean') { if (v) attrs.push(p.key) }
528
- else if (p.type === 'number') attrs.push(`${p.key}={${Number(v)}}`)
529
- else attrs.push(`${p.key}={${jsStr(String(v))}}`)
581
+ else attrs.push(`${p.key}={${propValueExpr(p, v)}}`)
530
582
  }
531
583
  // Baked-in array/object props (Timeline items, Sparkline data): emitted verbatim.
532
584
  for (const f of spec.fixed ?? []) attrs.push(`${f.key}={${f.expr}}`)
@@ -542,14 +594,37 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
542
594
  </div>`
543
595
  }
544
596
 
597
+ /** A JS expression for a component prop's configured value, by panel type. */
598
+ function propValueExpr(p: { type: string }, v: unknown): string {
599
+ if (p.type === 'number') return String(Number(v))
600
+ if (p.type === 'boolean') return String(!!v)
601
+ if (p.type === 'json') return JSON.stringify(v)
602
+ if (p.type === 'date') return `new Date(${jsStr(String(v))})`
603
+ return jsStr(String(v))
604
+ }
605
+
606
+ /** Extra SvGridEditPanel props for a grid's optional form-layout depth. Returns a
607
+ * leading-space attribute string (or '' when nothing is configured). */
608
+ function editPanelLayoutProps(grid?: GridConfig): string {
609
+ if (!grid) return ''
610
+ const attrs: string[] = []
611
+ if (grid.formColumns && grid.formColumns !== 2) attrs.push(`columns={${grid.formColumns}}`)
612
+ if (grid.formFields?.length) attrs.push(`formFields={${JSON.stringify(grid.formFields)}}`)
613
+ if (grid.formSections?.length) attrs.push(`sections={${JSON.stringify(grid.formSections)}}`)
614
+ if (grid.formTitle) attrs.push(`title={${jsStr(grid.formTitle)}}`)
615
+ if (grid.formSize && grid.formSize !== 'md') attrs.push(`formSize=${jsStr(grid.formSize)}`)
616
+ return attrs.length ? ' ' + attrs.join(' ') : ''
617
+ }
618
+
545
619
  /** The `{ props, text }` init literal for a component's reactive handle. */
546
620
  function handleInit(cfg: ComponentConfig): string {
547
621
  const spec = uiComponentSpec(cfg.component)
548
622
  const props: string[] = []
549
623
  for (const p of spec?.props ?? []) {
624
+ if (p.code) continue // code-only props are set via the handle in code, not seeded
550
625
  const v = cfg.props[p.key] ?? p.default
551
626
  if (v == null || v === '') continue
552
- props.push(`${p.key}: ${p.type === 'number' ? Number(v) : p.type === 'boolean' ? !!v : jsStr(String(v))}`)
627
+ props.push(`${p.key}: ${propValueExpr(p, v)}`)
553
628
  }
554
629
  for (const f of spec?.fixed ?? []) props.push(`${f.key}: ${f.expr}`)
555
630
  const parts = [`props: { ${props.join(', ')} }`]
@@ -636,14 +711,16 @@ function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelCo
636
711
  const { state, apply } = facetNames(block)
637
712
  const assigns = filterFieldsOf(entity, cfg).map((f) => {
638
713
  const key = jsStr(f.field)
639
- if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] === 'true' }`
714
+ // The server filter model takes string values; the matcher string-coerces both
715
+ // sides, so a boolean column matches fine against 'true' / 'false'.
716
+ if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] }`
640
717
  if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
641
718
  return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
642
719
  }).join('\n')
643
720
  return `let ${state} = $state<Record<string, string>>({})
644
721
  function ${apply}() {
645
722
  const v = ${state}
646
- const c: Record<string, { operator: 'equals' | 'contains'; value: unknown }> = {}
723
+ const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
647
724
  ${assigns}
648
725
  controller.setFilter({ columns: c })
649
726
  }`
@@ -717,7 +794,14 @@ const cellSnippetName = (idSafe: string, field: string): string => `cellRender_$
717
794
  /** Does this grid want an export toolbar (any export affordance enabled)? */
718
795
  function gridHasExport(cfg: GridConfig): boolean {
719
796
  const e = cfg.export
720
- 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)
721
805
  }
722
806
 
723
807
  /** Is this a tree-data grid (self-referential hierarchy)? */
@@ -812,6 +896,12 @@ function exportToolbarMarkup(e: NonNullable<GridConfig['export']>, apiVar: strin
812
896
  const btns: string[] = []
813
897
  if (e.csv) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.exportCsv({ filename: ${fn} })}>Export CSV</button>`)
814
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>`)
815
905
  if (e.copy) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.copyToClipboard()}>Copy</button>`)
816
906
  return ` <div class="st-grid-toolbar">\n ${btns.join('\n ')}\n </div>\n`
817
907
  }
@@ -827,6 +917,31 @@ const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutra
827
917
  return 'neutral'
828
918
  }`
829
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
+
830
945
  /** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
831
946
  function cellRendererSnippet(idSafe: string, field: string, cellType: NonNullable<GridColumnConfig['cellType']>): string {
832
947
  const name = cellSnippetName(idSafe, field)
@@ -1025,6 +1140,27 @@ function handleNameMap(screen: Screen): Map<string, string> {
1025
1140
  return new Map(screenHandles(screen).map((h) => [h.blockId, h.name]))
1026
1141
  }
1027
1142
 
1143
+ /** The `on<Event>` setter surface added to `ctx.grid` so `ctx.grid.onCellClick = fn`
1144
+ * type-checks. The `Row` placeholder in GRID_EVENTS params is the screen's row type. */
1145
+ function gridEventsType(rowType: string): string {
1146
+ return `{ ${GRID_EVENTS.map((e) => `${e.method}?: (${e.params.replace(/\bRow\b/g, rowType)}) => void`).join('; ')} }`
1147
+ }
1148
+
1149
+ /** The runtime-settable prop surface added to `ctx.grid` so `ctx.grid.sortable = true`
1150
+ * (and `if (ctx.grid.zebraRows)`) type-check + autocomplete. Assignments route to the
1151
+ * grid's `setOption` (a reactive $state override); reads route to `getOption`. */
1152
+ function gridPropsType(): string {
1153
+ const tsType = (p: { type: string; options?: string[] }): string =>
1154
+ p.type === 'boolean' ? 'boolean'
1155
+ : p.type === 'number' ? 'number'
1156
+ : p.type === 'select' && p.options?.length ? p.options.map((o) => JSON.stringify(o)).join(' | ')
1157
+ : p.type === 'json' ? 'unknown'
1158
+ : 'string'
1159
+ const props = gridApiSettableProps()
1160
+ if (!props.length) return '{}'
1161
+ return `{ ${props.map((p) => `${p.key}?: ${tsType(p)}`).join('; ')} }`
1162
+ }
1163
+
1028
1164
  /** Does this code-enabled screen expose a `ctx.data` dataset battery, and can code
1029
1165
  * replace its rows? Freestanding data-grid pages own their rows (settable via
1030
1166
  * setRows); an entity screen with a Grid exposes its current page + reload(). */
@@ -1088,7 +1224,7 @@ export function ctxCompletions(screen: Screen): string[] {
1088
1224
  for (const h of screenHandles(screen)) {
1089
1225
  const base = `ctx.${h.name}`
1090
1226
  out.push(base)
1091
- if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`))
1227
+ if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`), ...gridApiSettableProps().map((p) => `${base}.${p.key}`))
1092
1228
  else if (h.tier === 'data') out.push(...DATA_HANDLE_MEMBERS.map((m) => `${base}.${m}`))
1093
1229
  else if (h.component) out.push(...componentHandleMembers(h.component).map((m) => `${base}.${m}`))
1094
1230
  }
@@ -1180,7 +1316,7 @@ export function ctxAmbientDts(screen: Screen, entity?: EntitySchema): string {
1180
1316
 
1181
1317
  const members: string[] = []
1182
1318
  for (const h of handles) {
1183
- if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<${rowName}>`)
1319
+ if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<${rowName}> & ${gridEventsType(rowName)} & ${gridPropsType()}`)
1184
1320
  else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows). */\n ${h.name}: DataHandle<${rowName}>`)
1185
1321
  else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
1186
1322
  }
@@ -1227,7 +1363,7 @@ ${members.join('\n')}
1227
1363
  function screenElementsManifest(screen: Screen): string {
1228
1364
  const lines: string[] = []
1229
1365
  const describe: Record<HandleTier, (h: BlockHandle) => string> = {
1230
- grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...",
1366
+ grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...; subscribe to events (grid.onRowClick = (e) => {}); set options live (grid.sortable = true).",
1231
1367
  data: (h) => `the ${h.kind} - setData(rows) to feed it your own rows, .rows to read them, clear() to follow the screen data again.`,
1232
1368
  component: (h) => `the ${h.component} - setText/set<Prop>(...), onclick = fn, onClick(fn).`,
1233
1369
  }
@@ -1266,9 +1402,9 @@ export class ComponentHandle {
1266
1402
  get(name: string): unknown { return this.props[name] }
1267
1403
  setText(value: string): this { this.text = value; return this }
1268
1404
  setLabel(value: string): this { this.text = value; return this }
1269
- onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name]: fn }; return this }
1405
+ onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
1270
1406
  onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
1271
- fire(name: string, e: Event): void { this.on[name]?.(e) }
1407
+ fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
1272
1408
  }
1273
1409
 
1274
1410
  /** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
@@ -1290,6 +1426,42 @@ export class DataHandle<T = Record<string, unknown>> {
1290
1426
  /** A DataHandle whose fallback is the screen dataset getter. */
1291
1427
  export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
1292
1428
 
1429
+ /** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
1430
+ * are lowercased on both store + dispatch so any on<Event> casing round-trips. */
1431
+ class GridEvents {
1432
+ on: Record<string, (...args: unknown[]) => void> = {}
1433
+ fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
1434
+ }
1435
+ /** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
1436
+ * (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
1437
+ * The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
1438
+ export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
1439
+ const ev = new GridEvents()
1440
+ return new Proxy(ev, {
1441
+ get(t, k) {
1442
+ if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
1443
+ const api = getApi() as Record<string | symbol, unknown> | null
1444
+ if (!api) return undefined
1445
+ const v = api[k]
1446
+ if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
1447
+ // A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
1448
+ if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
1449
+ return v
1450
+ },
1451
+ set(t, k, val) {
1452
+ // onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
1453
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
1454
+ const api = getApi() as Record<string | symbol, unknown> | null
1455
+ // ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
1456
+ // a $state override the grid merges over its props). Falls back to a direct set for
1457
+ // an older grid build without setOption.
1458
+ if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
1459
+ if (api) api[k] = val
1460
+ return true
1461
+ },
1462
+ }) as A & GridEvents
1463
+ }
1464
+
1293
1465
  /** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
1294
1466
  export type Handle = ComponentHandle & Record<string, any>
1295
1467
 
@@ -1299,11 +1471,13 @@ export function handle(init: { props?: Record<string, unknown>; text?: string })
1299
1471
  get(t, k) {
1300
1472
  if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
1301
1473
  if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
1302
- if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k[2]!.toLowerCase() + k.slice(3); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1474
+ // onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
1475
+ if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
1303
1476
  return typeof k === 'string' ? t.props[k] : undefined
1304
1477
  },
1305
1478
  set(t, k, v) {
1306
- if (typeof k === 'string' && /^on[a-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1479
+ // el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
1480
+ if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
1307
1481
  if (k === 'text') { t.text = v as string; return true } // content, not a prop
1308
1482
  if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
1309
1483
  return true
@@ -1361,14 +1535,16 @@ function componentHandleTypeDecl(componentKey: string): string | null {
1361
1535
  * the PageContext type never drift. `rowType` is the entity's row type (entity
1362
1536
  * screens) or `RowData` (freestanding); `datasetRowsVar` is the state var backing
1363
1537
  * a settable `ctx.data`. */
1364
- function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean } {
1538
+ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean; usesGridHandle: boolean } {
1365
1539
  const blockById = new Map(flattenBlocks(screen.blocks).map((b) => [b.id, b]))
1366
1540
  const decls: string[] = []
1367
1541
  const ctxParts: string[] = []
1368
1542
  let usesHandle = false
1369
1543
  let usesDataHandle = false
1544
+ let usesGridHandle = false
1370
1545
  for (const h of screenHandles(screen)) {
1371
- if (h.tier === 'grid') { ctxParts.push('grid: gridApi!'); continue }
1546
+ // ctx.grid = the real SvGridApi PLUS event subscription (ctx.grid.onRowClick = fn).
1547
+ if (h.tier === 'grid') { usesGridHandle = true; decls.push('const gridCtx = gridHandle(() => gridApi)'); ctxParts.push('grid: gridCtx'); continue }
1372
1548
  if (h.tier === 'data') {
1373
1549
  usesDataHandle = true
1374
1550
  decls.push(`const ${h.name} = dataHandle<${rowType}>(() => allRows)`)
@@ -1386,9 +1562,9 @@ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | un
1386
1562
  if (dataset === 'settable' && datasetRowsVar) ctxParts.push(`data: { get rows() { return ${datasetRowsVar} }, setRows: (r) => (${datasetRowsVar} = r) }`)
1387
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) }')
1388
1564
  ctxParts.push('goto')
1389
- ctxParts.push('params: Object.fromEntries($page.url.searchParams)')
1565
+ ctxParts.push('params: Object.fromEntries(page.url.searchParams)')
1390
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(', ')} }`)
1391
- return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle }
1567
+ return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle, usesGridHandle }
1392
1568
  }
1393
1569
 
1394
1570
  /** Per-screen, regenerated PageContext type: the tiered handles (grid api / data
@@ -1404,7 +1580,7 @@ function screenContextFile(screen: Screen, rowType: string): GeneratedFile {
1404
1580
 
1405
1581
  const members: string[] = []
1406
1582
  for (const h of handles) {
1407
- if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<any, ${rowType}>`)
1583
+ if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<any, ${rowType}> & ${gridEventsType(rowType)} & ${gridPropsType()}`)
1408
1584
  else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows); ${h.name}.rows reads them. */\n ${h.name}: DataHandle<${rowType}>`)
1409
1585
  else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
1410
1586
  }
@@ -1445,27 +1621,25 @@ function compiledMethodBodies(screen: Screen): { onLoadSteps?: string; clicks?:
1445
1621
  if (!steps || !Object.keys(steps).length) return {}
1446
1622
  const names = handleNameMap(screen)
1447
1623
  const clicks: string[] = []
1624
+ // Legacy visual event steps compile to `ctx.<name>.on<event> = async () => { ... }`
1625
+ // inside onLoad. New event handlers are written as raw code directly in the onLoad
1626
+ // body (double-click an event -> Studio inserts `ctx.<name>.on<Event> = (e) => {}`).
1448
1627
  for (const b of flattenBlocks(screen.blocks)) {
1449
1628
  if (b.config.kind !== 'component') continue
1450
- const clickSteps = steps[clickSlot(b.id)]
1451
- if (!clickSteps?.length) continue
1452
- const name = names.get(b.id)
1453
- if (!name) continue
1454
- clicks.push(`ctx.${name}.onclick = async () => {\n${indentBody(compileHandlerSteps(clickSteps))}\n}`)
1455
- }
1456
- // Component value-change wiring (`ctx.<name>.onchange = ...`), same shape as clicks.
1457
- for (const b of flattenBlocks(screen.blocks)) {
1458
- if (b.config.kind !== 'component') continue
1459
- const changeSteps = steps[changeSlot(b.id)]
1460
- if (!changeSteps?.length) continue
1461
1629
  const name = names.get(b.id)
1462
1630
  if (!name) continue
1463
- clicks.push(`ctx.${name}.onchange = async () => {\n${indentBody(compileHandlerSteps(changeSteps))}\n}`)
1631
+ const spec = uiComponentSpec(b.config.component)
1632
+ const eventKeys = [...(spec?.events ?? []).map((e) => e.key), ...STANDARD_UI_EVENTS.map((e) => e.key)]
1633
+ for (const key of [...new Set(eventKeys)]) {
1634
+ const eventSteps = steps[eventSlot(key, b.id)]
1635
+ if (!eventSteps?.length) continue
1636
+ clicks.push(`ctx.${name}.on${key} = async () => {\n${indentBody(compileHandlerSteps(eventSteps))}\n}`)
1637
+ }
1464
1638
  }
1465
1639
  return {
1466
1640
  // Legacy visual onLoad steps (the code view now edits onLoad directly).
1467
1641
  onLoadSteps: steps[ON_LOAD]?.length ? compileHandlerSteps(steps[ON_LOAD]) : undefined,
1468
- // Component on-click wiring - MERGED with any hand-written onLoad body, never replacing it.
1642
+ // Component on-event wiring - MERGED with any hand-written onLoad body, never replacing it.
1469
1643
  clicks: clicks.length ? clicks.join('\n\n') : undefined,
1470
1644
  onDestroy: steps[ON_DESTROY]?.length ? compileHandlerSteps(steps[ON_DESTROY]) : undefined,
1471
1645
  }
@@ -1546,12 +1720,12 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1546
1720
  const gridImport = gridNames.length ? `import { ${gridNames.join(', ')} } from '@svgrid/grid'\n ` : ''
1547
1721
  const gridTypes = [grid ? 'RowData' : '', grid ? 'SvGridApi' : ''].filter(Boolean)
1548
1722
  const typeImport = hasCode && gridTypes.length ? `import type { ${gridTypes.join(', ')} } from '@svgrid/grid'\n ` : ''
1549
- const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
1723
+ const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : '', wiring?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
1550
1724
  const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
1551
1725
 
1552
1726
  const codeImport = hasCode ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
1553
1727
  const gotoImport = hasCode ? `import { goto } from '$app/navigation'\n ` : ''
1554
- const pageStoreImport = hasCode ? `import { page } from '$app/stores'\n ` : ''
1728
+ const pageStateImport = hasCode ? `import { page } from '$app/state'\n ` : ''
1555
1729
  const handleDecls = (wiring?.decls ?? []).join('\n ')
1556
1730
  // The Grid exposes its real SvGridApi (onApiReady) so code gets the full, typed
1557
1731
  // grid API - ctx.grid.exportCsv(), selectCells(), startEditing(), ... - not a stub.
@@ -1559,7 +1733,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1559
1733
  ? `\n let rows = $state<RowData[]>([])\n let gridApi = $state<SvGridApi<any, any> | null>(null)\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n const columns = $derived(rows.length ? Object.keys(rows[0]).map((field) => ({ field, header: field })) : [])`
1560
1734
  : ''
1561
1735
  const codeScript = hasCode
1562
- ? `\n ${handleDecls ? handleDecls + '\n ' : ''}${gridScript ? gridScript.trimStart() + '\n ' : ''}onMount(() => {
1736
+ ? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
1563
1737
  // ctx is internal plumbing wired from this screen's blocks; the typed surface your
1564
1738
  // code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
1565
1739
  // so the cast bridges their dynamic shape to the typed context.
@@ -1568,7 +1742,9 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1568
1742
  return () => handlers.${ON_DESTROY}(ctx)
1569
1743
  })`
1570
1744
  : ''
1571
- const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)} showRowNumbers />` : ''
1745
+ // Grid events fire into ctx.grid so freestanding page code can subscribe too.
1746
+ const gridEventAttrs = grid && wiring?.usesGridHandle ? GRID_EVENTS.filter((e) => e.prop).map((e) => ` ${e.prop}={(...a) => gridCtx.fire('${e.key}', ...a)}`).join('') : ''
1747
+ const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)}${gridEventAttrs} showRowNumbers />` : ''
1572
1748
 
1573
1749
  const blockContent = screen.blocks.length
1574
1750
  ? screen.blocks.map((b) => (b.config.kind === 'component' ? componentBlockMarkup(b, b.config, hasCode ? handleNames.get(b.id) : undefined) : '')).filter(Boolean).join('\n')
@@ -1580,7 +1756,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
1580
1756
  path: `src/routes/${screen.route}/+page.svelte`,
1581
1757
  description: `${screen.title} screen (freestanding, no bound entity).`,
1582
1758
  contents: `<script lang="ts">
1583
- ${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}
1584
1760
  </script>
1585
1761
 
1586
1762
  <h1 class="st__title">${title}</h1>
@@ -1594,7 +1770,7 @@ ${content}
1594
1770
  /** A self-contained screen page composing the screen's blocks. When `accessEnabled`
1595
1771
  * the page gates create / update affordances by the current role (server still
1596
1772
  * enforces via the route's `authorize`). */
1597
- function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false): GeneratedFile {
1773
+ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false, ssrData = false, entSource?: EntityDataSource): GeneratedFile {
1598
1774
  const n = namesFor(schema)
1599
1775
  const label = schema.label ?? n.label
1600
1776
  const blocks = screen.blocks
@@ -1642,8 +1818,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1642
1818
  const hasCellRenderers = cellRenderKinds.size > 0
1643
1819
  const wantsForm = !!formGrid || hasForm || hasEditAction
1644
1820
  // An unpaginated grid loads everything (one big page); else its configured size.
1645
- const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? gridConfigs[0].pageSize : 1000) : 10
1821
+ const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? (gridConfigs[0].pageSize ?? 10) : 1000) : 10
1646
1822
  const formPres = formGrid?.formPresentation ?? 'modal'
1823
+ // Optional form-layout depth (columns / field selection+order / titled sections /
1824
+ // dialog title+size) -> extra SvGridEditPanel props. Emitted only when configured.
1825
+ const formLayoutProps = editPanelLayoutProps(formGrid)
1647
1826
  const hasPivot = has(allBlocks, 'pivot')
1648
1827
  const hasFilter = has(blocks, 'filter')
1649
1828
  const hasRecord = has(blocks, 'record')
@@ -1651,27 +1830,18 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1651
1830
  // Filter panels drive the grid's controller; record panels read the grid's
1652
1831
  // selection - both need the controller even if the grid isn't editable.
1653
1832
  const needsController = hasGrid || wantsForm || hasFilter || hasRecord
1833
+ // Supabase Realtime: when the screen's entity is Supabase-backed and opts into
1834
+ // live updates, subscribe to Postgres change streams and refresh() the paged
1835
+ // grid on any INSERT / UPDATE / DELETE (respects the active sort/filter/page).
1836
+ const rtSupabase = !ssrData && needsController && entSource?.kind === 'supabase' && entSource.realtime === true
1837
+ const rtTable = rtSupabase ? (entSource as SupabaseSource).table : ''
1654
1838
  const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
1655
1839
  const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
1656
1840
 
1657
1841
  // Distinct, resolvable child entities referenced by master-detail blocks, and by
1658
1842
  // a detail page's related child collections (both load the child table into a
1659
1843
  // `md_<name>_rows` state var + filter it by the foreign key at render time).
1660
- const mdChildren = new Map<string, EntitySchema>()
1661
- for (const b of blocks) {
1662
- if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
1663
- const c = resolve(b.config.childEntity)
1664
- if (c) mdChildren.set(c.name, c)
1665
- }
1666
- if (b.config.kind === 'detail') {
1667
- for (const rel of b.config.related ?? []) {
1668
- if (!rel.entity || !rel.foreignKey) continue
1669
- const c = resolve(rel.entity)
1670
- if (c) mdChildren.set(c.name, c)
1671
- }
1672
- }
1673
- }
1674
- const childList = [...mdChildren.values()]
1844
+ const childList = screenChildEntities(blocks, resolve)
1675
1845
  const hasMD = childList.length > 0
1676
1846
  // Components with a data binding (aggregate / field) read the whole table too.
1677
1847
  const boundComponents = allBlocks.filter((b): b is Block & { config: ComponentConfig } => b.config.kind === 'component' && componentHasBindings(b.config))
@@ -1733,8 +1903,16 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1733
1903
  if (hasPivot) entImports.push('SvPivotDesigner')
1734
1904
  // The scheduler renderer (grid scheduler-view + calendar block) is registered app-wide (idempotent).
1735
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')
1911
+ if (rtSupabase) entImports.push('createSupabaseRealtime', 'type SupabaseRealtimeClientLike')
1736
1912
  // Dedupe: record + form both want SvGridEditPanel.
1737
1913
  const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
1914
+ // Realtime pulls the shared client from the generated connections module.
1915
+ const connImport = rtSupabase ? `import { supabaseClient } from '$lib/connections'\n ` : ''
1738
1916
  const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
1739
1917
  const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
1740
1918
  const childTypes = childList.map((c) => namesFor(c).type)
@@ -1743,7 +1921,8 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1743
1921
  // (childEntity === this entity) doesn't emit a duplicate import specifier.
1744
1922
  const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
1745
1923
  const typeImports = [...new Set([n.type, ...childTypes])]
1746
- const dataImports = [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
1924
+ // SSR read pages get every dataset from the server load - no client source use.
1925
+ const dataImports = ssrData ? [] : [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
1747
1926
 
1748
1927
  // Drill-through: this screen navigates out (goto) and/or is a drill target that
1749
1928
  // reads URL query params matching its fields into an initial filter.
@@ -1769,16 +1948,25 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1769
1948
  for (const a of screenActions) parts.push(actionHandlerScript(a))
1770
1949
  if (needsController) {
1771
1950
  const urlFilter = applyUrlFilters
1772
- ? `\n const sp = $page.url.searchParams
1951
+ ? `\n const sp = page.url.searchParams
1773
1952
  const _cols: Record<string, { operator: 'equals'; value: string }> = {}
1774
1953
  for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
1775
1954
  if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
1776
1955
  : ''
1956
+ const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`
1957
+ // In code mode, wrap CRUD so page code can react to data changes via ctx.grid
1958
+ // (grid.onRowAdded / onRowUpdated / onRowDeleted). Fires only after a write settles.
1959
+ const ctlDecl = codeGrid
1960
+ ? `const __ctl = ${ctlBase}\n const controller = { ...__ctl, createRow: async (i: Partial<${n.type}>) => { const r = await __ctl.createRow(i); gridCtx.fire('rowAdded', r); return r }, updateRow: async (id: string, p: Partial<${n.type}>) => { const r = await __ctl.updateRow(id, p); gridCtx.fire('rowUpdated', r); return r }, deleteRow: async (id: string) => { await __ctl.deleteRow(id); gridCtx.fire('rowDeleted', id) } }`
1961
+ : `const controller = ${ctlBase}`
1962
+ const rtWiring = rtSupabase
1963
+ ? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
1964
+ return () => { __rt.unsubscribe(); controller.dispose() } })`
1965
+ : `\n controller.refresh(); return () => controller.dispose() })`
1777
1966
  parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
1778
1967
  let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
1779
- const controller = createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })
1780
- $effect(() => {${urlFilter}
1781
- controller.refresh(); return () => controller.dispose() })`)
1968
+ ${ctlDecl}
1969
+ $effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`)
1782
1970
  }
1783
1971
  const actionSnippets: string[] = []
1784
1972
  // Tree-grid scripts reference `allRows`; collected here, appended AFTER its declaration.
@@ -1787,6 +1975,14 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1787
1975
  if (blocks.some((b) => b.config.kind === 'grid' && b.config.columns.some((c) => c.show && c.cellType?.kind === 'badge'))) {
1788
1976
  parts.push(BADGE_VARIANT_HELPER)
1789
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
+ }
1790
1986
  for (const b of blocks) {
1791
1987
  if (b.config.kind === 'grid') {
1792
1988
  const idSafe = b.id.replace(/-/g, '_')
@@ -1813,10 +2009,17 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1813
2009
  }
1814
2010
  }
1815
2011
  if (needsAllRows) {
1816
- parts.push(`let allRows = $state<${n.type}[]>([])
2012
+ // SSR read screens get their rows from the server load (real SSR HTML);
2013
+ // the SPA path fetches client-side after mount.
2014
+ parts.push(
2015
+ ssrData
2016
+ ? `const allRows = $derived(data.rows as ${n.type}[])
2017
+ const allRowsReady = true`
2018
+ : `let allRows = $state<${n.type}[]>([])
1817
2019
  let allRowsReady = $state(false)
1818
2020
  async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
1819
- loadAll()`)
2021
+ loadAll()`,
2022
+ )
1820
2023
  }
1821
2024
  // Tree-grid state/derivations - emitted after `allRows` so it's in scope.
1822
2025
  for (const s of treeScripts) parts.push(s)
@@ -1840,6 +2043,10 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1840
2043
  for (const c of childList) {
1841
2044
  const cn = namesFor(c)
1842
2045
  const v = mdChildVar(c.name)
2046
+ if (ssrData) {
2047
+ parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`)
2048
+ continue
2049
+ }
1843
2050
  parts.push(`let ${v} = $state<${cn.type}[]>([])
1844
2051
  async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
1845
2052
  load_${v}()`)
@@ -1925,7 +2132,7 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
1925
2132
  const steps = b.config.kind === 'grid' ? screen.handlerSteps?.[rowSelectSlot(b.id)] : undefined
1926
2133
  return steps?.length ? compileHandlerSteps(steps) : undefined
1927
2134
  }
1928
- const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral })
2135
+ const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral, gridEventSink: codeGrid && b.id === codeGridBlockId ? 'gridCtx' : undefined })
1929
2136
  const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, blockCtx(b, false))).filter(Boolean).join('\n')
1930
2137
  // Dock mode: each block becomes a pane rendered by id; a narrow viewport stacks the grid body.
1931
2138
  const dockPanes = isDock
@@ -1961,7 +2168,7 @@ ${canvasBody}
1961
2168
  ${body}
1962
2169
  </div>`
1963
2170
  const modal = wantsForm
1964
- ? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}" persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
2171
+ ? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}"${formLayoutProps} persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
1965
2172
  : ''
1966
2173
  // currentRole is needed for either a create/update UI gate or an action's
1967
2174
  // screen-access gate; `can`/`canScreen` are pulled in only where actually used.
@@ -1969,13 +2176,13 @@ ${body}
1969
2176
  const accessSpecs = [...(needsCurrentRole ? ['currentRole'] : []), ...(gatesUi ? ['can'] : []), ...(gatesActions ? ['canScreen'] : [])]
1970
2177
  const accessImport = accessSpecs.length ? `import { ${accessSpecs.join(', ')} } from '$lib/access'\n ` : ''
1971
2178
  const i18nImport = i18nEnabled ? `import { t, localizeCols } from '$lib/i18n'\n ` : ''
1972
- // 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
1973
2180
  // block otherwise navigates; and the handle runtime for its data/component handles.
1974
2181
  const gotoImport = usesGoto || codeEnabled ? `import { goto } from '$app/navigation'\n ` : ''
1975
2182
  const codeImport = codeEnabled ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
1976
- const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
2183
+ const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : '', codeWire?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
1977
2184
  const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
1978
- 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 ` : ''
1979
2186
  const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
1980
2187
  // Surface a failed data load (silent empty grid otherwise) with a retry.
1981
2188
  const errorBanner = needsController
@@ -1991,10 +2198,9 @@ ${body}
1991
2198
  path: `src/routes/${screen.route}/+page.svelte`,
1992
2199
  description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
1993
2200
  contents: `<script lang="ts">
1994
- ${gridImports}${entImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
1995
- import { ${dataImports.join(', ')} } from '$lib/data'
1996
-
1997
- ${parts.join('\n\n ')}
2201
+ ${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
2202
+ ${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
2203
+ ${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
1998
2204
  </script>
1999
2205
 
2000
2206
  <h1 class="st__title">${title}</h1>
@@ -2019,6 +2225,376 @@ ${(() => {
2019
2225
  }
2020
2226
  }
2021
2227
 
2228
+ /** Distinct, resolvable child entities a screen's master-detail / detail blocks
2229
+ * reference - shared by the SPA loader, the SSR read `load`, and the page's
2230
+ * `md_<name>_rows` derivations so the three can never drift. */
2231
+ function screenChildEntities(blocks: Block[], resolve: (name: string) => EntitySchema | undefined): EntitySchema[] {
2232
+ const out = new Map<string, EntitySchema>()
2233
+ for (const b of blocks) {
2234
+ if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
2235
+ const c = resolve(b.config.childEntity)
2236
+ if (c) out.set(c.name, c)
2237
+ }
2238
+ if (b.config.kind === 'detail') {
2239
+ for (const rel of b.config.related ?? []) {
2240
+ if (!rel.entity || !rel.foreignKey) continue
2241
+ const c = resolve(rel.entity)
2242
+ if (c) out.set(c.name, c)
2243
+ }
2244
+ }
2245
+ }
2246
+ return [...out.values()]
2247
+ }
2248
+
2249
+ /** `+page.server.ts` for a read-only SSR screen (data-viz / detail / master-
2250
+ * detail): a `load` that returns the full dataset (+ each child collection) -
2251
+ * the page renders real SSR HTML from `data.*`. No actions (read-only). */
2252
+ function ssrReadServerFile(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], resolve: (name: string) => EntitySchema | undefined): GeneratedFile {
2253
+ const n = namesFor(schema)
2254
+ const isSql = sourceKind === 'sql'
2255
+ const children = screenChildEntities(screen.blocks, resolve)
2256
+ const rbac = accessEnabled && !isSql // sql: the /api route already enforces authz
2257
+ const srcExpr = (s: EntitySchema) =>
2258
+ isSql ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + namesFor(s).route)}, fetch })` : namesFor(s).sourceVar
2259
+ const memImports = isSql ? [] : [...new Set([n.sourceVar, ...children.map((c) => namesFor(c).sourceVar)])]
2260
+ const childLoads = children.map((c) => ` const ${mdChildVar(c.name)} = (await ${srcExpr(c)}.getRows(PAGE)).rows`).join('\n')
2261
+ return {
2262
+ path: `src/routes/${screen.route}/+page.server.ts`,
2263
+ description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
2264
+ contents: `import type { PageServerLoad } from './$types'
2265
+ import type { ServerRequest } from '@svgrid/grid'
2266
+ ${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
2267
+ // The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
2268
+ // INTO server rendering - real SSR HTML, not just a server-side load.
2269
+ export const ssr = true
2270
+ ${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
2271
+ const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
2272
+
2273
+ export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
2274
+ ${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
2275
+ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
2276
+ }
2277
+ `,
2278
+ }
2279
+ }
2280
+
2281
+ // ---------------------------------------------------------------------------
2282
+ // SSR-native output (opt-in per screen via `renderMode: 'ssr'`).
2283
+ //
2284
+ // Instead of the client data-source-controller page, an SSR screen emits idiomatic
2285
+ // SvelteKit: a `+page.server.ts` with a `load` (SSR first paint, sort/filter/page
2286
+ // read from the URL so it's shareable and works with no JS) and form `actions` for
2287
+ // create / update / delete (progressive enhancement via `use:enhance`), plus an
2288
+ // SSR `+page.svelte` that renders the server rows and drives the grid's external
2289
+ // sort/pagination back into the URL. Server-side validation runs in the actions.
2290
+ // ---------------------------------------------------------------------------
2291
+
2292
+ /** URL search params -> a data-source request. Shared by every SSR route's load. */
2293
+ const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
2294
+ // page / size). The grid drives these params via goto(), so the server re-runs
2295
+ // load() - which makes every list view bookmarkable and functional with no JS.
2296
+ import type { ServerRequest } from '@svgrid/grid'
2297
+
2298
+ export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
2299
+ const sp = url.searchParams
2300
+ const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
2301
+ const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
2302
+ const sortModel = (sp.get('sort') ?? '')
2303
+ .split(',')
2304
+ .filter(Boolean)
2305
+ .map((token) => {
2306
+ const [id, dir] = token.split(':')
2307
+ return { id: id!, desc: dir === 'desc' }
2308
+ })
2309
+ const columns: Record<string, { operator: 'contains'; value: string }> = {}
2310
+ for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
2311
+ const global = sp.get('q') ?? ''
2312
+ return {
2313
+ startRow: pageIndex * pageSize,
2314
+ endRow: pageIndex * pageSize + pageSize,
2315
+ pageIndex,
2316
+ pageSize,
2317
+ sortModel,
2318
+ filterModel: { ...(global ? { global } : {}), columns },
2319
+ }
2320
+ }
2321
+ `
2322
+
2323
+ function ssrQueryHelperFile(): GeneratedFile {
2324
+ return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER }
2325
+ }
2326
+
2327
+ const htmlEsc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2328
+
2329
+ /** The `+page.server.ts` + SSR `+page.svelte` for a single-grid CRUD screen. */
2330
+ function emitSsrGridScreen(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], byName: Map<string, EntitySchema>): GeneratedFile[] {
2331
+ const n = namesFor(schema)
2332
+ const isSql = sourceKind === 'sql'
2333
+ const relSchemaOf = (f: EntityField) => byName.get(f.relation!.entity)!
2334
+ const relIdOf = (rs: EntitySchema) => rs.idField ?? rs.fields.find((x) => x.primaryKey)?.field ?? 'id'
2335
+ const grid = screen.blocks[0]!.config as GridConfig
2336
+ const pageSize = grid.pageSize && grid.pageSize > 0 ? grid.pageSize : 25
2337
+ const wantsFilter = grid.filterable !== false
2338
+ const idField = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
2339
+ const isFormHidden = (f: EntityField) => f.hidden === true || (typeof f.hidden === 'object' && !!f.hidden.form)
2340
+ const formFields = schema.fields.filter(
2341
+ (f) => f.field !== idField && !f.primaryKey && !f.readonly && !f.computed && !f.formula && !isFormHidden(f),
2342
+ )
2343
+ const normType = (t: string): 'text' | 'number' | 'boolean' => (t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'text')
2344
+ const fieldTypesLit = `{ ${formFields.map((f) => `${jsStr(f.field)}: ${jsStr(normType(f.type))}`).join(', ')} }`
2345
+
2346
+ // Relation fields render as a native <select> whose options are prefetched in load
2347
+ // (memory path only for now; sql relation fields stay a text FK - follow-up).
2348
+ const isRel = (f: EntityField) => f.type === 'relation' && !!f.relation && byName.has(f.relation!.entity)
2349
+ const relFields = formFields.filter(isRel)
2350
+ // The $lib/data import only needs the related sources on the memory path; on sql
2351
+ // the related options come from the related entity's /api route (via event.fetch).
2352
+ const relSourceVars = isSql ? [] : [...new Set(relFields.map((f) => namesFor(relSchemaOf(f)).sourceVar))]
2353
+ const relPrefetch = relFields
2354
+ .map((f) => {
2355
+ const rs = relSchemaOf(f)
2356
+ const rel = namesFor(rs)
2357
+ const relId = relIdOf(rs)
2358
+ const labelF = f.relation!.labelField ?? relId
2359
+ const relSource = isSql
2360
+ ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + rel.route)}, fetch })`
2361
+ : rel.sourceVar
2362
+ return ` const ${f.field}Options = (await ${relSource}.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rows.map((r: Record<string, unknown>) => ({ value: String(r[${jsStr(relId)}] ?? ''), label: String(r[${jsStr(labelF)}] ?? '') }))`
2363
+ })
2364
+ .join('\n')
2365
+ const relReturn = relFields.map((f) => `, ${f.field}Options`).join('')
2366
+
2367
+ // Source acquisition differs by kind:
2368
+ // - memory: import the in-process source from $lib/data and call it directly.
2369
+ // - sql: build a same-origin client over the connected /api/<entity> route with
2370
+ // SvelteKit's event.fetch, so validation / RBAC / triggers / audit stay enforced
2371
+ // once, in that route's createKitHandlers - not duplicated here.
2372
+ const src = isSql ? 'source(fetch)' : n.sourceVar
2373
+ const fetchArg = isSql ? ', fetch' : ''
2374
+ const enterpriseImports = isSql ? 'validateAll, createKitDataSource' : 'validateAll'
2375
+ const sourceImport = isSql ? '' : `import { ${[...new Set([n.sourceVar, ...relSourceVars]), 'nextId'].join(', ')} } from '$lib/data'\n`
2376
+ const schemaImport = `import { ${n.schemaVar}${isSql ? `, type ${n.type}` : ''} } from '$lib/schemas'`
2377
+ const idConst = isSql ? '' : `\nconst ID_FIELD = ${jsStr(idField)}`
2378
+ const srcHelper = isSql
2379
+ ? `\n// Same-origin client over the connected /api/${n.route} route (that route runs\n// validation, RBAC, triggers + audit via createKitHandlers).\nconst source = (fetch: typeof globalThis.fetch) => createKitDataSource<${n.type}>({ endpoint: ${jsStr('/api/' + n.route)}, fetch })\n`
2380
+ : ''
2381
+ const createCall = isSql
2382
+ ? `await ${src}.createRow(values)`
2383
+ : `await ${n.sourceVar}.createRow({ [ID_FIELD]: nextId(${jsStr(n.idPrefix)}), ...values })`
2384
+
2385
+ // RBAC only needs inline enforcement for the memory path; sql inherits it from the
2386
+ // connected /api route (createKitHandlers authorize), reached via event.fetch.
2387
+ const rbac = accessEnabled && !isSql
2388
+ const localsArg = rbac ? ', locals' : ''
2389
+ const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''
2390
+ const writeGuard = (action: string) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '')
2391
+
2392
+ const server = `import type { Actions, PageServerLoad } from './$types'
2393
+ import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
2394
+ import { ${enterpriseImports} } from '@svgrid/enterprise'
2395
+ ${rbac ? "import { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${sourceImport}${schemaImport}
2396
+ import { planFromSearchParams } from '$lib/server/query'
2397
+
2398
+ // The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
2399
+ // INTO server rendering - real SSR HTML, not just a server-side load.
2400
+ export const ssr = true
2401
+ ${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
2402
+ const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
2403
+ ${srcHelper}
2404
+ /** Read a submitted form into a typed partial row. Booleans come from checkbox
2405
+ * presence; numbers are coerced; empty values are dropped so they don't clobber. */
2406
+ function formToValues(fd: FormData): Record<string, unknown> {
2407
+ const values: Record<string, unknown> = {}
2408
+ for (const [field, type] of Object.entries(FIELD_TYPES)) {
2409
+ if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
2410
+ const raw = fd.get(field)
2411
+ if (raw == null || raw === '') continue
2412
+ values[field] = type === 'number' ? Number(raw) : String(raw)
2413
+ }
2414
+ return values
2415
+ }
2416
+
2417
+ export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
2418
+ ${readGuard} const plan = planFromSearchParams(url, ${pageSize})
2419
+ const { rows, rowCount } = await ${src}.getRows(plan)
2420
+ ${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
2421
+ }
2422
+
2423
+ export const actions: Actions = {
2424
+ create: async ({ request${fetchArg}${localsArg} }) => {
2425
+ ${writeGuard('create')} const values = formToValues(await request.formData())
2426
+ const errors = await validateAll(${n.schemaVar}, values)
2427
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2428
+ ${createCall}
2429
+ return { ok: true }
2430
+ },
2431
+ update: async ({ request${fetchArg}${localsArg} }) => {
2432
+ const fd = await request.formData()
2433
+ ${writeGuard('update')} const id = String(fd.get('__id') ?? '')
2434
+ const values = formToValues(fd)
2435
+ const errors = await validateAll(${n.schemaVar}, values)
2436
+ if (Object.keys(errors).length) return fail(422, { errors, values })
2437
+ await ${src}.updateRow(id, values)
2438
+ return { ok: true }
2439
+ },
2440
+ delete: async ({ request${fetchArg}${localsArg} }) => {
2441
+ ${writeGuard('delete')} const fd = await request.formData()
2442
+ await ${src}.deleteRow(String(fd.get('__id') ?? ''))
2443
+ return { ok: true }
2444
+ },
2445
+ }
2446
+ `
2447
+
2448
+ const row = `(editing as Record<string, unknown>)`
2449
+ const fieldBlocks = formFields
2450
+ .map((f) => {
2451
+ const key = jsStr(f.field)
2452
+ const req = f.required ? ' required' : ''
2453
+ let input: string
2454
+ if (normType(f.type) === 'boolean') {
2455
+ input = `<input type="checkbox" name=${key} checked={!isCreate && !!${row}[${key}]} />`
2456
+ } else if (f.options && f.options.length) {
2457
+ const opts = f.options
2458
+ .map((o) => `<option value=${jsStr(String(o.value))} selected={!isCreate && ${row}[${key}] === ${jsStr(String(o.value))}}>${htmlEsc(o.label ?? String(o.value))}</option>`)
2459
+ .join('')
2460
+ input = `<select name=${key}${req}>${opts}</select>`
2461
+ } else if (isRel(f)) {
2462
+ // Relation: options prefetched in load (data.<field>Options), current FK selected.
2463
+ input = `<select name=${key}${req}>\n <option value="">-</option>\n {#each data.${f.field}Options as o (o.value)}<option value={o.value} selected={!isCreate && String(${row}[${key}] ?? '') === o.value}>{o.label}</option>{/each}\n </select>`
2464
+ } else {
2465
+ const t = normType(f.type) === 'number' ? 'number' : f.type === 'date' || f.type === 'dateString' ? 'date' : 'text'
2466
+ input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req} />`
2467
+ }
2468
+ return ` <label class="sk-field">
2469
+ <span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
2470
+ ${input}
2471
+ {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
2472
+ </label>`
2473
+ })
2474
+ .join('\n')
2475
+
2476
+ const page = `<script lang="ts">
2477
+ import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
2478
+ import { schemaToColumns } from '@svgrid/enterprise'
2479
+ import { goto } from '$app/navigation'
2480
+ import { page } from '$app/state'
2481
+ import { enhance } from '$app/forms'
2482
+ import type { SubmitFunction } from '@sveltejs/kit'
2483
+ import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
2484
+ import type { PageProps } from './$types'
2485
+
2486
+ let { data, form }: PageProps = $props()
2487
+
2488
+ const ID_FIELD = ${jsStr(idField)}
2489
+ const TITLE = ${jsStr(screen.title)}
2490
+ const NEW_LABEL = ${jsStr('New ' + n.label)}
2491
+
2492
+ // Grid columns from the schema + a row-actions column (Edit / Delete).
2493
+ const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
2494
+ ...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
2495
+ { id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
2496
+ ]
2497
+
2498
+ // null = no editor; 'create' = new row; a row = editing that row.
2499
+ let editing = $state<'create' | ${n.type} | null>(null)
2500
+ const isCreate = $derived(editing === 'create')
2501
+
2502
+ // Sort / paginate by writing to the URL - load() re-runs on the server.
2503
+ function setParams(patch: Record<string, string | null>) {
2504
+ const sp = new URLSearchParams(page.url.searchParams)
2505
+ for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
2506
+ const q = sp.toString()
2507
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2508
+ }
2509
+ ${wantsFilter ? `
2510
+ // Filter via the URL (q = global search, f_<col> = per-column). The server's
2511
+ // planFromSearchParams reads these, so load() re-filters; reset to the first page.
2512
+ function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
2513
+ const sp = new URLSearchParams(page.url.searchParams)
2514
+ for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
2515
+ if (f.global) sp.set('q', f.global)
2516
+ for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
2517
+ sp.delete('page')
2518
+ const q = sp.toString()
2519
+ void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
2520
+ }
2521
+ ` : ''}
2522
+ // Progressive enhancement: post to the action, keep typed values on error,
2523
+ // close the editor on success (load re-runs automatically, refreshing the grid).
2524
+ const onSubmit: SubmitFunction = () => async ({ result, update }) => {
2525
+ await update({ reset: false })
2526
+ if (result.type === 'success') editing = null
2527
+ }
2528
+ </script>
2529
+
2530
+ {#snippet rowActions({ row }: { row: ${n.type} })}
2531
+ <div class="sk-rowact">
2532
+ <button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
2533
+ <form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
2534
+ <input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
2535
+ <button type="submit" class="sk-link sk-danger">Delete</button>
2536
+ </form>
2537
+ </div>
2538
+ {/snippet}
2539
+
2540
+ <header class="sk-head">
2541
+ <h1>{TITLE}</h1>
2542
+ <button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
2543
+ </header>
2544
+
2545
+ <SvGrid
2546
+ data={data.rows}
2547
+ {columns}
2548
+ externalSort
2549
+ initialSorting={data.sort}
2550
+ externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
2551
+ rowCount={data.total}
2552
+ pageIndex={data.page}
2553
+ pageSize={data.size}
2554
+ onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
2555
+ onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
2556
+ />
2557
+
2558
+ {#if editing}
2559
+ <div class="sk-overlay">
2560
+ <form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
2561
+ <h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
2562
+ {#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
2563
+ ${fieldBlocks}
2564
+ <div class="sk-form__actions">
2565
+ <button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
2566
+ <button type="submit" class="sk-btn sk-btn--primary">Save</button>
2567
+ </div>
2568
+ </form>
2569
+ </div>
2570
+ {/if}
2571
+
2572
+ <style>
2573
+ .sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
2574
+ .sk-head h1 { margin: 0; font-size: 20px; }
2575
+ .sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
2576
+ .sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
2577
+ .sk-rowact { display: flex; gap: 10px; }
2578
+ .sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
2579
+ .sk-danger { color: #dc2626; }
2580
+ .sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
2581
+ .sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
2582
+ .sk-form h2 { margin: 0 0 4px; font-size: 16px; }
2583
+ .sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
2584
+ .sk-field span { color: var(--sg-muted, #64748b); }
2585
+ .sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
2586
+ .sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
2587
+ .sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
2588
+ .sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
2589
+ </style>
2590
+ `
2591
+
2592
+ return [
2593
+ { path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
2594
+ { path: `src/routes/${screen.route}/+page.svelte`, description: `${n.label} screen (SSR + progressive enhancement).`, contents: page },
2595
+ ]
2596
+ }
2597
+
2022
2598
  export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2023
2599
  if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
2024
2600
  if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
@@ -2039,7 +2615,13 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2039
2615
  if (s.entity === undefined) continue // freestanding screen - gates no entity route
2040
2616
  screensByEntity.set(s.entity, [...(screensByEntity.get(s.entity) ?? []), s.id])
2041
2617
  }
2042
- const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers })
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) })
2043
2625
  const byName = new Map(prepared.map((s) => [s.name, s]))
2044
2626
  // Raw (unprepared) entities keep their original field set - needed to derive
2045
2627
  // relation display-field names that match withRelationLabels (the prepared
@@ -2083,8 +2665,25 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2083
2665
  }
2084
2666
  const schema = byName.get(screen.entity)
2085
2667
  if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
2086
- pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled))
2668
+ // SSR-native path (opt-in): a grid screen gets load + form actions + URL-
2669
+ // driven sort/filter/page; a read-only screen (data-viz / detail) gets a
2670
+ // load-only server file, and the SAME page markup renders from data.*.
2671
+ if (isSsrScreen(project, screen)) {
2672
+ const srcKind = sources[screen.entity]?.kind === 'sql' ? 'sql' : 'memory'
2673
+ if (ssrScreenShape(project, screen) === 'grid') {
2674
+ pages.push(...emitSsrGridScreen(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], byName))
2675
+ } else {
2676
+ pages.push(
2677
+ ssrReadServerFile(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], resolve),
2678
+ screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, true),
2679
+ )
2680
+ }
2681
+ continue
2682
+ }
2683
+ pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, false, sources[screen.entity]))
2087
2684
  }
2685
+ // Any SSR screen needs the shared URL-params -> query helper (server-only).
2686
+ const ssrHelpers = project.screens.some((s) => isSsrScreen(project, s)) ? [ssrQueryHelperFile()] : []
2088
2687
  const actionRouteFiles = [...actionsById.values()].map(({ action, screenId }) => actionRouteFile(action, screenId, accessEnabled))
2089
2688
 
2090
2689
  // Nav: only screens flagged into the menu, ordered, with an optional custom label.
@@ -2099,21 +2698,39 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
2099
2698
  // auth requires that active layer, so its user store can live in the same schema.
2100
2699
  const firstSqlSrc = sqlEntities.length ? sources[sqlEntities[0]!.name] : undefined
2101
2700
  const dataLayerActive = project.dataLayer === 'drizzle' && sqlEntities.length > 0 && dzDialect(firstSqlSrc?.kind === 'sql' ? firstSqlSrc.dialect : undefined) !== null
2102
- const dbBackedAuth = authEnabled && dataLayerActive
2701
+ // Supabase Auth: a client-side SvAuthGate over the shared Supabase client, in place
2702
+ // of the builtin cookie-session scaffold. Mutually exclusive with the builtin
2703
+ // provider, and it never uses the DB-backed user store.
2704
+ const supabaseAuth = authEnabled && project.auth?.provider === 'supabase'
2705
+ const dbBackedAuth = authEnabled && !supabaseAuth && dataLayerActive
2103
2706
  const authRegister = dbBackedAuth && project.auth?.register === true
2104
2707
  const authUserAdmin = dbBackedAuth && accessEnabled && project.auth?.userAdmin === true
2105
2708
  const authTwoFactor = dbBackedAuth && project.auth?.twoFactor === true
2106
- const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor) : []
2107
- const authFileList = authEnabled ? authFiles(project, dbBackedAuth, accessEnabled) : []
2108
- const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
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) : []
2713
+ // Without the Drizzle layer (or on MSSQL, which it can't cover), ship plain SQL the
2714
+ // user runs once against their database - otherwise the tables must pre-exist.
2715
+ const ddlFiles = sqlEntities.length > 0 && !dataLayerActive ? sqlDdlFiles(project.entities, sources) : []
2716
+ // The builtin cookie-session auth files (hooks.server, /login, session store, ...)
2717
+ // are skipped for the Supabase provider - SvAuthGate replaces them.
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)] : []
2109
2726
  // Routes that render bare (no shell) + skip the login guard.
2110
- const publicAuthRoutes = authEnabled ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
2727
+ const publicAuthRoutes = authEnabled && !supabaseAuth ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
2111
2728
  let navExtras = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
2112
2729
  // The admin Users screen is nav-gated by canScreen('__users__') - only full-access ('*') roles see it.
2113
2730
  if (authUserAdmin) navExtras = [...navExtras, { href: '/users', label: 'Users', id: '__users__' }]
2114
2731
  const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
2115
2732
  const handleFiles = project.screens.some(screenHasCode) ? [handlesModuleFile()] : []
2116
- return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...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, 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)]
2117
2734
  }
2118
2735
 
2119
2736
  /** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
@@ -2142,7 +2759,14 @@ function i18nModule(project: StudioProject): GeneratedFile {
2142
2759
  const en = buildMessages(project)
2143
2760
  const localeUnion = locales.map((l) => JSON.stringify(l)).join(' | ')
2144
2761
  const seeded = JSON.stringify(en, null, 2).replace(/\n/g, '\n ')
2145
- 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')
2146
2770
  return {
2147
2771
  path: 'src/lib/i18n.ts',
2148
2772
  description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
@@ -2152,8 +2776,9 @@ export type Locale = ${localeUnion}
2152
2776
  export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
2153
2777
  export const currentLocale = writable<Locale>(${JSON.stringify(def)})
2154
2778
 
2155
- // The default locale is seeded from your schema + screen labels. Fill the other
2156
- // 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.
2157
2782
  const messages: Record<Locale, Record<string, string>> = {
2158
2783
  ${messagesEntries}
2159
2784
  }
@@ -2172,12 +2797,18 @@ export function localizeCols<T extends { field?: string | number; header?: strin
2172
2797
  }
2173
2798
  }
2174
2799
 
2175
- /** The audit store: an in-memory `ServerDataSource` of change records + a
2176
- * `recordAudit` writer. Swap the source for SQL/Supabase to persist the trail. */
2177
- 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()
2178
2809
  return {
2179
2810
  path: 'src/lib/audit.ts',
2180
- 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.',
2181
2812
  contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
2182
2813
  import type { EntitySchema } from '@svgrid/enterprise'
2183
2814
 
@@ -2236,6 +2867,248 @@ export async function recordAudit(input: {
2236
2867
  }
2237
2868
  }
2238
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
+
2239
3112
  /** The read API for the audit trail (the /audit viewer reads it via the transport). */
2240
3113
  function auditRouteFile(): GeneratedFile {
2241
3114
  return {
@@ -2358,7 +3231,7 @@ export function authorizeAction(role: AppRole, action: 'read' | WriteAction, scr
2358
3231
  * `hooks.server.ts` that resolves the caller into `event.locals.role`/`user` (the loop
2359
3232
  * the RBAC layer already expects), a `/login` page + sign-out, and the `App.Locals`
2360
3233
  * type augmentation. Works across every data source (no DB required for the demo). */
2361
- function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false): GeneratedFile[] {
3234
+ function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false, tenantColumn?: string): GeneratedFile[] {
2362
3235
  const users = seedUsers(project)
2363
3236
  const demo = users[0]!
2364
3237
  const protect = project.auth?.protect !== false
@@ -2590,15 +3463,24 @@ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
2590
3463
  }` : ''}
2591
3464
  `
2592
3465
  : `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
2593
- // an external identity provider, ...). Passwords here are demo seeds, like sample rows:
2594
- // change them and store hashes for production (see hashPassword / verifyPassword in ./auth).
2595
- // Tip: turn on the Drizzle data layer and this becomes a real \`auth_users\` DB table.
2596
- export type AppUser = { email: string; name: string; role: string; password: string }
3466
+ // an external identity provider, ...). The seed passwords below are demo defaults
3467
+ // (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
3468
+ // in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
3469
+ // this becomes a real \`auth_users\` DB table.
3470
+ import { hashPassword } from './auth'
2597
3471
 
2598
- export const USERS: AppUser[] = [
3472
+ export type AppUser = { email: string; name: string; role: string; passwordHash: string }
3473
+
3474
+ const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
2599
3475
  ${usersLiteral},
2600
3476
  ]
2601
3477
 
3478
+ // Hash the seed passwords once at module load (top-level await) so the store holds
3479
+ // only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
3480
+ export const USERS: AppUser[] = await Promise.all(
3481
+ SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
3482
+ )
3483
+
2602
3484
  export function findUser(email: string): AppUser | undefined {
2603
3485
  const e = email.trim().toLowerCase()
2604
3486
  return USERS.find((u) => u.email.toLowerCase() === e)
@@ -2613,7 +3495,10 @@ import { SESSION_COOKIE, readSession } from '$lib/server/auth'
2613
3495
  export const handle: Handle = async ({ event, resolve }) => {
2614
3496
  const user = await readSession(event.cookies.get(SESSION_COOKIE))
2615
3497
  event.locals.user = user ?? undefined
2616
- 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}` : ''}
2617
3502
  return resolve(event)
2618
3503
  }
2619
3504
  `
@@ -2637,7 +3522,7 @@ export {}
2637
3522
  const layoutServerTs = `import type { LayoutServerLoad } from './$types'
2638
3523
  ${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
2639
3524
  // Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
2640
- // or \`$page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
3525
+ // or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
2641
3526
  export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
2642
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 }
2643
3528
  }
@@ -2648,9 +3533,8 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
2648
3533
  // DB-backed store: verify against the stored PBKDF2 hash.
2649
3534
  if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
2650
3535
  : `const user = findUser(email)
2651
- // DEMO: plain comparison against the seed. For a real store keep a passwordHash and
2652
- // use \`await verifyPassword(password, user.passwordHash)\` from '$lib/server/auth'.
2653
- if (!user || user.password !== password) return fail(401, { email, error: 'Invalid email or password.' })`
3536
+ // Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
3537
+ if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
2654
3538
  // 2FA (email code): after the password check, email a code + park a pending token,
2655
3539
  // then send the user to /login/verify instead of issuing the session immediately.
2656
3540
  const twoFactorBranch = twoFactor ? `
@@ -2663,7 +3547,7 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
2663
3547
  }` : ''
2664
3548
  const loginImports = twoFactor
2665
3549
  ? "import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, signSession, verifyPassword, otpCode, signChallenge } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'\nimport { sendEmail } from '$lib/server/email'"
2666
- : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession${dbBacked ? ', verifyPassword' : ''} } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
3550
+ : `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
2667
3551
  const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
2668
3552
  import type { Actions, PageServerLoad } from './$types'
2669
3553
  ${loginImports}
@@ -3376,8 +4260,44 @@ function dzColumn(dialect: DzDialect, field: EntityField, colName: string, isPk:
3376
4260
  /** The Drizzle `auth_users` table (added when auth + data layer are both on): the
3377
4261
  * DB-backed user store the login flow reads. Fixed shape: id, unique email, name,
3378
4262
  * role, passwordHash. Returns the table block + the core imports it needs. */
3379
- function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false): { block: string; imports: string[] } {
3380
- 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`
3381
4301
  if (dialect === 'postgres') {
3382
4302
  const tfa = with2fa ? '\n "twoFactor": boolean("two_factor").notNull().default(false),' : ''
3383
4303
  return {
@@ -3404,7 +4324,7 @@ function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false): { b
3404
4324
  * there's a SQL-bound entity on a supported dialect): a schema (the source of truth
3405
4325
  * for drizzle-kit migrations), a client, a typed repository per entity, and the
3406
4326
  * drizzle.config.ts. The connected `+server.ts` routes read the same tables. */
3407
- 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[] {
3408
4328
  const firstSql = sources[sqlEntities[0]!.name]
3409
4329
  const dialect = dzDialect(firstSql?.kind === 'sql' ? firstSql.dialect : undefined)
3410
4330
  if (!dialect) return [] // MSSQL: raw route only (Drizzle has no SQL Server driver)
@@ -3427,15 +4347,29 @@ function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sou
3427
4347
  c.imports.forEach((i) => colImports.add(i))
3428
4348
  return ` ${JSON.stringify(f.field)}: ${c.expr},`
3429
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
+ }
3430
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`)
3431
4359
  meta.push({ e, tableVar, pkKey: pk, pkNumber })
3432
4360
  }
3433
4361
  // Auth: the DB-backed user store lives in the same schema (so one migration covers it).
3434
4362
  if (includeUsers) {
3435
- const u = dzUsersTable(dialect, cfg.table, usersTwoFactor)
4363
+ const u = dzUsersTable(dialect, cfg.table, usersTwoFactor, tenantColumn)
3436
4364
  u.imports.forEach((i) => colImports.add(i))
3437
4365
  tableBlocks.push(u.block)
3438
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
+ }
3439
4373
  const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
3440
4374
  // truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
3441
4375
  import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
@@ -3512,6 +4446,60 @@ export default defineConfig({
3512
4446
  dialect: '${cfg.kit}',
3513
4447
  ${cfg.creds},
3514
4448
  })
4449
+ `
4450
+
4451
+ // db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
4452
+ // freshly-migrated production database doesn't open onto blank grids. Lives outside
4453
+ // src/ (like drizzle.config.ts) and builds its own client from process.env, because
4454
+ // the app's client imports $env/dynamic/private which only exists under Vite.
4455
+ const { seed: seedMap } = prepareEntities(sqlEntities)
4456
+ const nameSet = new Set(sqlEntities.map((e) => e.name))
4457
+ // Parents before children so FK values resolve (simple repeated-pass ordering).
4458
+ const ordered: typeof meta = []
4459
+ const pending = [...meta]
4460
+ while (pending.length) {
4461
+ const next = pending.findIndex(({ e }) =>
4462
+ e.fields.every((f) => f.type !== 'relation' || !f.relation || !nameSet.has(f.relation.entity) || f.relation.entity === e.name || ordered.some((o) => o.e.name === f.relation!.entity)),
4463
+ )
4464
+ ordered.push(...pending.splice(next === -1 ? 0 : next, 1))
4465
+ }
4466
+ const pkNumberByName = new Map(meta.map((m) => [m.e.name, m.pkNumber]))
4467
+ const seedBlocks = ordered
4468
+ .map(({ e, tableVar, pkKey, pkNumber }) => {
4469
+ const rows = (seedMap.get(e.name) ?? []).map((row, i) => {
4470
+ const out: Record<string, unknown> = { ...row }
4471
+ if (pkNumber) out[pkKey] = i + 1
4472
+ for (const f of e.fields) {
4473
+ if (f.type === 'relation' && f.relation && pkNumberByName.get(f.relation.entity)) {
4474
+ const relCount = seedMap.get(f.relation.entity)?.length ?? 1
4475
+ out[f.field] = (i % Math.max(1, relCount)) + 1
4476
+ }
4477
+ }
4478
+ return out
4479
+ })
4480
+ if (!rows.length) return null
4481
+ return ` {
4482
+ const existing = await db.select().from(schema.${tableVar}).limit(1)
4483
+ if (existing.length === 0) {
4484
+ await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
4485
+ console.log('seeded ${e.name} (${rows.length} rows)')
4486
+ } else console.log('${e.name} already has rows - skipped')
4487
+ }`
4488
+ })
4489
+ .filter((b): b is string => b !== null)
4490
+ const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
4491
+ // Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
4492
+ // so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
4493
+ ${cfg.client}
4494
+ import * as schema from '../src/lib/server/db/schema'
4495
+
4496
+ ${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
4497
+
4498
+ async function main() {
4499
+ ${seedBlocks.join('\n')}
4500
+ }
4501
+
4502
+ main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
3515
4503
  `
3516
4504
 
3517
4505
  return [
@@ -3519,6 +4507,7 @@ export default defineConfig({
3519
4507
  { path: 'src/lib/server/db/index.ts', description: 'Drizzle client.', contents: indexTs },
3520
4508
  ...repoFiles,
3521
4509
  { path: 'drizzle.config.ts', description: 'drizzle-kit config (migrations).', contents: configTs },
4510
+ ...(seedBlocks.length ? [{ path: 'db/seed.ts', description: 'Sample-row seeder (run after db:migrate: npm run db:seed).', contents: seedTs }] : []),
3522
4511
  ]
3523
4512
  }
3524
4513
 
@@ -3529,7 +4518,10 @@ const appSlug = (title: string): string =>
3529
4518
 
3530
4519
  /** The static SvelteKit + Vite scaffolding around the generated screens. */
3531
4520
  const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
3532
- { path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [sveltekit()] })\n` },
4521
+ // watch.ignored: `studio dev` runs this app's Vite next to the designer, which
4522
+ // auto-saves studio.config.json + a .studio/ manifest into the same folder -
4523
+ // neither should trigger a reload of the app.
4524
+ { path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [sveltekit()],\n server: { watch: { ignored: ['**/.studio/**', '**/studio.config.json'] } },\n})\n` },
3533
4525
  { path: 'tsconfig.json', description: 'TypeScript config.', contents: `{\n "extends": "./.svelte-kit/tsconfig.json",\n "compilerOptions": {\n "allowJs": true,\n "checkJs": true,\n "esModuleInterop": true,\n "forceConsistentCasingInFileNames": true,\n "resolveJsonModule": true,\n "skipLibCheck": true,\n "sourceMap": true,\n "strict": true,\n "moduleResolution": "bundler"\n }\n}\n` },
3534
4526
  { path: 'src/app.html', description: 'HTML shell.', contents: `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n %sveltekit.head%\n </head>\n <body data-sveltekit-preload-data="hover">\n <div style="display: contents">%sveltekit.body%</div>\n </body>\n</html>\n` },
3535
4527
  { path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
@@ -3770,12 +4762,15 @@ export function studioDeployInfo(project: StudioProject): { label: string; cli:
3770
4762
  }
3771
4763
 
3772
4764
  /** DEPLOY.md - the runbook: required env vars, then Git-integration / CI / CLI paths. */
3773
- function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean): string {
4765
+ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean, opts: { hasDdl?: boolean; hasSeedScript?: boolean } = {}): string {
3774
4766
  const envBlock = envKeys.length
3775
- ? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\n`
4767
+ ? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\nOptional: \`VITE_SVPRO_KEY\` (your SvGrid license key) removes the unlicensed watermark - call \`setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\` once at startup (see the docs).\n\n`
3776
4768
  : ''
3777
4769
  const migrations = hasMigrations
3778
- ? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
4770
+ ? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n${opts.hasSeedScript ? 'npm run db:seed # optional: insert the sample rows\n' : ''}\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
4771
+ : ''
4772
+ const ddl = opts.hasDdl
4773
+ ? `## Database tables\n\nThis app's SQL entities expect their tables to exist. Run \`db/schema.sql\` against the database your \`DATABASE_URL\` points at (psql / mysql / sqlite3 / sqlcmd, or your provider's SQL console) before first use - and optionally \`db/seed.sql\` for sample rows. Tip: enable the Drizzle data layer in Studio for real, versioned migrations instead.\n\n`
3779
4774
  : ''
3780
4775
  const gitIntegration = plan.dashboard
3781
4776
  ? `## Option A - Git integration (simplest, no secrets)\n\n1. Push this repo to GitHub.\n2. Import it at <${plan.dashboard}> - the SvelteKit build is auto-detected.\n3. Add the environment variables above in the host's dashboard.\n\nEvery push to \`main\` then redeploys automatically.\n\n`
@@ -3787,7 +4782,7 @@ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[],
3787
4782
  ? `## Container\n\nA \`Dockerfile\` is included:\n\n\`\`\`bash\ndocker build -t ${appSlug(project.title)} .\ndocker run -p 3000:3000 --env-file .env ${appSlug(project.title)}\n\`\`\`\n\nThe server listens on \`PORT\` (default 3000).\n\n`
3788
4783
  : ''
3789
4784
  const cli = `## Option C - Deploy from your machine\n\n\`\`\`bash\nnpm run deploy\n\`\`\`\n\n(runs \`${plan.deployScript}\`)\n`
3790
- return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
4785
+ return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${ddl}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
3791
4786
  }
3792
4787
 
3793
4788
  /** The env-var keys the generated app reads (for DEPLOY.md). */
@@ -3795,10 +4790,37 @@ function envKeysUsed(allSource: string): string[] {
3795
4790
  const keys: string[] = []
3796
4791
  if (allSource.includes('env.DATABASE_URL')) keys.push('DATABASE_URL')
3797
4792
  if (allSource.includes('env.DATABASE_AUTH_TOKEN')) keys.push('DATABASE_AUTH_TOKEN')
4793
+ if (allSource.includes('PUBLIC_SUPABASE_URL')) keys.push('PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_ANON_KEY')
3798
4794
  if (allSource.includes('SESSION_SECRET')) keys.push('SESSION_SECRET')
4795
+ // Feature keys - same detection as envExample, so DEPLOY.md's checklist matches
4796
+ // .env.example instead of silently omitting the auth/email vars.
4797
+ if (allSource.includes("from '$lib/server/email'")) keys.push('EMAIL_FROM', 'RESEND_API_KEY or SMTP_HOST/PORT/USER/PASS')
4798
+ if (allSource.includes("from '$lib/server/oauth'")) {
4799
+ if (allSource.includes("'github'") || allSource.includes('"github"')) keys.push('GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET')
4800
+ if (allSource.includes("'google'") || allSource.includes('"google"')) keys.push('GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET')
4801
+ if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) keys.push('OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET')
4802
+ }
3799
4803
  return keys
3800
4804
  }
3801
4805
 
4806
+ /** A cryptographically-random hex secret. Portable across Node 19+ and browsers
4807
+ * (the visual designer runs codegen client-side), so every generated app gets a
4808
+ * unique real session secret instead of a shared hardcoded fallback. */
4809
+ function randomSecret(bytes = 32): string {
4810
+ const buf = new Uint8Array(bytes)
4811
+ globalThis.crypto.getRandomValues(buf)
4812
+ return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
4813
+ }
4814
+
4815
+ /** A ready-to-run, git-ignored `.env`: the `.env.example` template with a real
4816
+ * random `SESSION_SECRET` filled in, so the app is secure out of the box (no
4817
+ * hardcoded dev secret). Only emitted when the app actually signs sessions. */
4818
+ function envDotFile(allSource: string): string | null {
4819
+ const example = envExample(allSource)
4820
+ if (!example || !allSource.includes('SESSION_SECRET')) return null
4821
+ return example.replace('SESSION_SECRET=change-me-to-a-long-random-string', `SESSION_SECRET=${randomSecret()}`)
4822
+ }
4823
+
3802
4824
  /** A `.env.example` listing the env vars the generated code reads, when any. */
3803
4825
  function envExample(allSource: string): string | null {
3804
4826
  const lines: string[] = []
@@ -3810,6 +4832,12 @@ function envExample(allSource: string): string | null {
3810
4832
  lines.push('# Turso / libSQL database auth token.')
3811
4833
  lines.push('DATABASE_AUTH_TOKEN=')
3812
4834
  }
4835
+ if (allSource.includes('PUBLIC_SUPABASE_URL')) {
4836
+ if (lines.length) lines.push('')
4837
+ lines.push('# Supabase project (Settings > API). The anon key is public / browser-safe.')
4838
+ lines.push('PUBLIC_SUPABASE_URL=')
4839
+ lines.push('PUBLIC_SUPABASE_ANON_KEY=')
4840
+ }
3813
4841
  if (allSource.includes('SESSION_SECRET')) {
3814
4842
  if (lines.length) lines.push('')
3815
4843
  lines.push('# Session signing secret - set a long random value in production.')
@@ -3835,6 +4863,12 @@ function envExample(allSource: string): string | null {
3835
4863
  if (allSource.includes("'google'") || allSource.includes('"google"')) { lines.push('# GOOGLE_CLIENT_ID='); lines.push('# GOOGLE_CLIENT_SECRET=') }
3836
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=') }
3837
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
+ }
3838
4872
  if (lines.length === 0) return null
3839
4873
  lines.push('')
3840
4874
  lines.push('# Optional: your SvGrid license key removes the unlicensed watermark.')
@@ -3846,7 +4880,9 @@ function svelteConfig(plan: DeployPlan): string {
3846
4880
  return `import adapter from '${plan.adapterModule}'\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n preprocess: vitePreprocess(),\n kit: { adapter: adapter() },\n}\n\nexport default config\n`
3847
4881
  }
3848
4882
 
3849
- function packageJson(project: StudioProject, allSource: string): string {
4883
+ /** The runtime npm deps the generated source imports, keyed by package -> range.
4884
+ * One source of truth for package.json AND the fragment-mode dependency report. */
4885
+ export function runtimeDeps(project: StudioProject, allSource: string): Record<string, string> {
3850
4886
  const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
3851
4887
  if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
3852
4888
  if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
@@ -3860,18 +4896,33 @@ function packageJson(project: StudioProject, allSource: string): string {
3860
4896
  if (/from ['"]hyperformula['"]/.test(allSource)) dependencies['hyperformula'] = '^3.3.0'
3861
4897
  if (/from ['"]jszip['"]/.test(allSource)) dependencies['jszip'] = '^3.10.1'
3862
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
+ }
3863
4910
  // nodemailer is dynamically imported by the email layer only on the SMTP branch.
3864
- const usesNodemailer = /import\(['"]nodemailer['"]\)/.test(allSource)
3865
- if (usesNodemailer) dependencies['nodemailer'] = '^6.9.0'
3866
- // Typed data layer: drizzle-orm at runtime, drizzle-kit (dev) for migrations + scripts.
3867
- const drizzle = project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)
3868
- if (drizzle) dependencies['drizzle-orm'] = '^0.44.0'
4911
+ if (/import\(['"]nodemailer['"]\)/.test(allSource)) dependencies['nodemailer'] = '^6.9.0'
4912
+ if (project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['drizzle-orm'] = '^0.44.0'
4913
+ return dependencies
4914
+ }
4915
+
4916
+ function packageJson(project: StudioProject, allSource: string): string {
4917
+ const dependencies = runtimeDeps(project, allSource)
4918
+ const drizzle = !!dependencies['drizzle-orm']
3869
4919
  const scripts: Record<string, string> = { dev: 'vite dev', build: 'vite build', preview: 'vite preview', check: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', test: 'vitest run', deploy: deployPlan(project).deployScript }
3870
4920
  if (drizzle) {
3871
4921
  scripts['db:generate'] = 'drizzle-kit generate'
3872
4922
  scripts['db:migrate'] = 'drizzle-kit migrate'
3873
4923
  scripts['db:push'] = 'drizzle-kit push'
3874
4924
  scripts['db:studio'] = 'drizzle-kit studio'
4925
+ if (allSource.includes('studio:db-seed')) scripts['db:seed'] = 'tsx db/seed.ts'
3875
4926
  }
3876
4927
  const pkg = {
3877
4928
  name: appSlug(project.title),
@@ -3896,8 +4947,9 @@ function packageJson(project: StudioProject, allSource: string): string {
3896
4947
  // Driver typings so the connected route + data layer type-check cleanly.
3897
4948
  ...(dependencies['pg'] ? { '@types/pg': '^8.11.0' } : {}),
3898
4949
  ...(dependencies['better-sqlite3'] ? { '@types/better-sqlite3': '^7.6.0' } : {}),
3899
- ...(usesNodemailer ? { '@types/nodemailer': '^6.4.0' } : {}),
4950
+ ...(dependencies['nodemailer'] ? { '@types/nodemailer': '^6.4.0' } : {}),
3900
4951
  ...(drizzle ? { 'drizzle-kit': '^0.31.0' } : {}),
4952
+ ...(drizzle && allSource.includes('studio:db-seed') ? { tsx: '^4.19.0' } : {}),
3901
4953
  },
3902
4954
  }
3903
4955
  return JSON.stringify(pkg, null, 2) + '\n'
@@ -3961,6 +5013,66 @@ export default defineConfig({
3961
5013
  * Download it, `npm install`, `npm run dev`. This is what the designer's
3962
5014
  * "Download .zip" produces.
3963
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
+
3964
5076
  export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
3965
5077
  const generated = emitStudioProject(project)
3966
5078
  const allSource = generated.map((f) => f.contents).join('\n')
@@ -3975,8 +5087,10 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
3975
5087
  { path: 'vitest.config.ts', description: 'Test runner config for the generated smoke tests (npm test).', contents: VITEST_CONFIG },
3976
5088
  { path: 'src/lib/schemas.test.ts', description: 'Smoke tests: every entity renders + round-trips through its data source.', contents: smokeTestFile(project) },
3977
5089
  ...plan.files,
5090
+ ...cronScheduleFiles(project, plan),
3978
5091
  ...SCAFFOLD_STATIC,
3979
5092
  ...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
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)! }] : []),
3980
5094
  { path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS },
3981
5095
  // Your styles: a dedicated CSS file the layout imports AFTER app.css, so its rules
3982
5096
  // win. Edited in the designer's Styles panel; regenerated from the model.
@@ -3989,7 +5103,65 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
3989
5103
  // has one, and a DEPLOY.md runbook.
3990
5104
  { path: '.github/workflows/ci.yml', description: 'CI: build + test on push / PR.', contents: CI_WORKFLOW },
3991
5105
  ...(plan.deployWorkflow ? [{ path: '.github/workflows/deploy.yml', description: `Deploy to ${plan.label} on push to main.`, contents: plan.deployWorkflow }] : []),
3992
- { path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts')) },
5106
+ { path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts'), { hasDdl: generated.some((f) => f.path === 'db/schema.sql'), hasSeedScript: generated.some((f) => f.path === 'db/seed.ts') }) },
3993
5107
  ]
3994
5108
  return [...scaffold, ...generated]
3995
5109
  }
5110
+
5111
+ /**
5112
+ * Emit ONLY the app content (`src/routes` + `src/lib` + `db`) to drop into an
5113
+ * EXISTING SvelteKit app - not a whole new app. Everything emitStudioProject
5114
+ * produces MINUS the nav shell (`+layout.svelte`) and the home redirect (home
5115
+ * `+page.svelte`), PLUS `src/app.css` (the `.st-*` page styles + `--sg-*` tokens,
5116
+ * which otherwise live only in the full bundle) and a FRAGMENT.md that lists the
5117
+ * deps to install and what the host app must provide.
5118
+ */
5119
+ export function emitStudioFragment(project: StudioProject): GeneratedFile[] {
5120
+ const generated = emitStudioProject(project)
5121
+ const allSource = generated.map((f) => f.contents).join('\n')
5122
+ // Drop the two files that assume ownership of the whole app.
5123
+ const content = generated.filter((f) => f.path !== 'src/routes/+layout.svelte' && f.path !== 'src/routes/+page.svelte')
5124
+
5125
+ const deps = runtimeDeps(project, allSource)
5126
+ const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n')
5127
+ const envKeys = envKeysUsed(allSource)
5128
+ const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``)
5129
+ const fragmentMd = `# Studio fragment - drop into your SvelteKit app
5130
+
5131
+ This folder holds ONLY app content - routes under \`src/routes\` and modules under
5132
+ \`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
5133
+ so it drops into your existing SvelteKit app.
5134
+
5135
+ ## 1. Copy the files
5136
+ Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
5137
+ SvelteKit alias, so imports resolve with no config. Watch for filename collisions
5138
+ with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
5139
+
5140
+ ## 2. Install dependencies
5141
+ \`\`\`bash
5142
+ npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
5143
+ \`\`\`
5144
+ Full list this fragment imports:
5145
+ \`\`\`
5146
+ ${depList}
5147
+ \`\`\`
5148
+
5149
+ ## 3. Styles
5150
+ Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
5151
+ \`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
5152
+
5153
+ ## 4. What your app must provide
5154
+ The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
5155
+ and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
5156
+ generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
5157
+
5158
+ ## Files included
5159
+ ${libFiles.join('\n')}
5160
+ `
5161
+
5162
+ return [
5163
+ ...content,
5164
+ { path: 'src/app.css', description: 'Page styles (.st-*) + design tokens - import once in your layout.', contents: APP_CSS },
5165
+ { path: 'FRAGMENT.md', description: 'How to drop this fragment into an existing SvelteKit app.', contents: fragmentMd },
5166
+ ]
5167
+ }