@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
@@ -34,6 +34,24 @@ export type SqlDialectKind = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'supaba
34
34
  /** In-memory (seeded) source. `seed` carries curated rows (e.g. a sample app);
35
35
  * when absent the codegen + preview synthesize realistic rows. */
36
36
  export type MemorySource = { kind: 'memory'; seed?: Record<string, unknown>[] }
37
+ /** Wire-format preset that teaches the REST source how a backend pages / sorts /
38
+ * wraps its rows, so the generated grid does REAL server-side paging + sort (not a
39
+ * single fetched page). Maps to the `offsetLimit` / `dummyjson` / `jsonServer`
40
+ * adapters in `@svgrid/enterprise`. Absent = the legacy manual `rowsPath`/`totalPath`
41
+ * (parse only; no server paging). */
42
+ export type RestAdapterConfig =
43
+ | { kind: 'dummyjson'; rowsKey?: string }
44
+ | { kind: 'jsonServer' }
45
+ | {
46
+ kind: 'offsetLimit'
47
+ offsetParam?: string
48
+ limitParam?: string
49
+ sortByParam?: string
50
+ orderParam?: string
51
+ searchParam?: string
52
+ rowsKey?: string
53
+ totalKey?: string
54
+ }
37
55
  export type RestSource = {
38
56
  kind: 'rest'
39
57
  /** Origin + version prefix, e.g. `https://api.example.com/v1`. */
@@ -43,13 +61,16 @@ export type RestSource = {
43
61
  method: RestMethod
44
62
  params: RequestParam[]
45
63
  idField?: string
64
+ /** Wire-format preset for real server paging/sort (offsetLimit / dummyjson /
65
+ * jsonServer). When set, it supersedes `rowsPath`/`totalPath`. */
66
+ adapter?: RestAdapterConfig
46
67
  /** Dotted path in the response body holding the rows, e.g. `data.items`. */
47
68
  rowsPath?: string
48
69
  /** Dotted path holding the total row count, e.g. `data.total`. */
49
70
  totalPath?: string
50
71
  }
51
- export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind }
52
- export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string }
72
+ export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind; /** DB schema / search path (Postgres/MSSQL); default `public`. Qualifies the table. */ schema?: string }
73
+ export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string; realtime?: boolean }
53
74
  /** Embedded Postgres (PGlite) - a real, persistent database with ZERO backend
54
75
  * setup. Runs in the browser, persisting to IndexedDB. Swap for a `sql` source
55
76
  * pointed at hosted Postgres to go to production (same schema + SQL). */
@@ -100,6 +121,15 @@ export type GridConfig = {
100
121
  editing: GridEditing
101
122
  /** Presentation of the edit form when `editing === 'form'`. */
102
123
  formPresentation: Presentation
124
+ /** Form layout depth (all optional; default = the auto 2-column form of all fields). */
125
+ formColumns?: 1 | 2 | 3
126
+ /** Restrict + order the form's fields (field names). */
127
+ formFields?: string[]
128
+ /** Group fields into titled fieldsets. */
129
+ formSections?: Array<{ title?: string; fields: string[] }>
130
+ /** Dialog title override + width for the modal/drawer form. */
131
+ formTitle?: string
132
+ formSize?: 'sm' | 'md' | 'lg'
103
133
  density: GridDensity
104
134
  striped: boolean
105
135
  /** Excel-style cell/range selection. */
@@ -135,6 +165,10 @@ export type GridConfig = {
135
165
  * Kanban board). Mutually exclusive with grouping / tree. Uses the enterprise
136
166
  * scheduler renderer (`enableSchedulerView`). */
137
167
  scheduler?: SchedulerViewConfig
168
+ /** Raw SvGrid prop overrides from the block's "All properties" panel - every grid
169
+ * prop the curated controls don't manage. Passed straight through to `<SvGrid>`
170
+ * (deduped against the curated props at codegen time). */
171
+ props?: Record<string, unknown>
138
172
  }
139
173
  /** Which calendar view the scheduler opens on / offers. */
140
174
  export type SchedulerViewMode = 'month' | 'week' | 'day' | 'agenda' | 'timelineDay' | 'timelineWeek' | 'timelineMonth' | 'timelineYear'
@@ -169,7 +203,20 @@ export type SchedulerViewConfig = {
169
203
  export type TreeDataConfig = { parentField: string; labelField: string }
170
204
  /** Which export affordances the grid toolbar shows. All go through the grid's built-in
171
205
  * export API (dependency-free). Empty / all-false = no toolbar. */
172
- export type GridExportConfig = { csv?: boolean; json?: boolean; copy?: boolean }
206
+ /** Export buttons on a grid's toolbar.
207
+ *
208
+ * `csv` / `json` / `copy` use the free grid API. `xlsx` / `pdf` / `print` go
209
+ * through `@svgrid/enterprise` (real OOXML, pdfmake, paginated print) and pull
210
+ * in their optional peer deps - jszip for xlsx, pdfmake for pdf - which the
211
+ * generated package.json declares only when the button is switched on. */
212
+ export type GridExportConfig = {
213
+ csv?: boolean
214
+ json?: boolean
215
+ copy?: boolean
216
+ xlsx?: boolean
217
+ pdf?: boolean
218
+ print?: boolean
219
+ }
173
220
  /** Which filtering surfaces the grid shows (when `filterable`). `global` = the search-all
174
221
  * box, `row` = a filter input under each header, `menu` = the column header filter menu. */
175
222
  export type GridFilterUi = { global?: boolean; row?: boolean; menu?: boolean }
@@ -360,7 +407,9 @@ export function blockClassName(block: Pick<Block, 'className'>): string {
360
407
  }
361
408
  /** The number of columns (1-12) a block occupies in the 12-col layout. */
362
409
  export const blockColumns = (b: Pick<Block, 'span' | 'colSpan'>): number =>
363
- Math.max(1, Math.min(12, Math.round(b.colSpan ?? b.span * 4)))
410
+ // Default to full width when a block sets neither colSpan nor span (Copilot /
411
+ // hand-authored configs may omit both) - never emit `span NaN` into the HTML.
412
+ Math.max(1, Math.min(12, Math.round(b.colSpan ?? (b.span != null ? b.span * 4 : 12))))
364
413
 
365
414
  /** Restrict a user-typed color to a safe subset so it can't break out of an inline
366
415
  * `style="..."` attribute (hex, rgb()/hsl(), named colors, css vars). */
@@ -408,10 +457,49 @@ export const clickSlot = (blockId: string) => `click:${blockId}`
408
457
  export const rowSelectSlot = (blockId: string) => `rowSelect:${blockId}`
409
458
  /** The handler-steps key for a component block's change (value change) event. */
410
459
  export const changeSlot = (blockId: string) => `change:${blockId}`
460
+ /** Generic event slot - `click`/`change` produce the same keys as clickSlot/changeSlot,
461
+ * so existing projects keep their wiring; any other declared component event
462
+ * (focus, select, toggle, ...) gets its own `<event>:<blockId>` slot. */
463
+ export const eventSlot = (event: string, blockId: string) => `${event}:${blockId}`
411
464
  /** The handler-steps key for the screen's form-submit (record saved) event. The
412
465
  * compiled steps get the submitted `row` (values) in scope. */
413
466
  export const FORM_SUBMIT = 'formSubmit'
414
467
 
468
+ /** One event on the Grid, exposed to code-behind as `ctx.grid.<method> = (e) => {}`.
469
+ * `prop` is the SvGrid callback it wires to (empty for `dataEvent`s, which fire from
470
+ * Studio's data controller on create/update/delete rather than from the grid markup). */
471
+ export type GridEventDef = { key: string; method: string; prop: string; params: string; builtin: boolean; desc: string; dataEvent?: boolean }
472
+ /** The Grid's real event surface (mirrors SvGrid's `on*` callbacks). Studio wires
473
+ * every one into `ctx.grid` so page code can subscribe with `ctx.grid.onCellClick =
474
+ * (e) => {}`. `params` is the handler's parameter list, with `Row` as the row-type
475
+ * placeholder (substituted per screen). `builtin` marks events a Studio feature may
476
+ * already use (sort/filter/paginate/edit/row-click) - codegen COMPOSES the user's
477
+ * handler onto the built-in rather than emitting a duplicate prop. Signatures are
478
+ * transcribed from packages/grid/src/SvGrid.types.ts. */
479
+ export const GRID_EVENTS: readonly GridEventDef[] = [
480
+ { key: 'rowClick', method: 'onRowClick', prop: 'onRowClick', builtin: true, desc: 'A data row is single-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
481
+ { key: 'rowDoubleClick', method: 'onRowDoubleClick', prop: 'onRowDoubleClick', builtin: true, desc: 'A data row is double-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
482
+ { key: 'cellClick', method: 'onCellClick', prop: 'onCellClick', builtin: false, desc: 'A data cell is single-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
483
+ { key: 'cellDoubleClick', method: 'onCellDoubleClick', prop: 'onCellDoubleClick', builtin: false, desc: 'A data cell is double-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
484
+ { key: 'rowSelectionChange', method: 'onRowSelectionChange', prop: 'onRowSelectionChange', builtin: false, desc: 'The row selection changes.', params: 'selection: Record<string, boolean>, rows: Row[]' },
485
+ { key: 'cellSelectionChange', method: 'onCellSelectionChange', prop: 'onCellSelectionChange', builtin: false, desc: 'The cell-selection rectangle changes.', params: 'ranges: Array<[number, number, number, number]>' },
486
+ { key: 'activeCellChange', method: 'onActiveCellChange', prop: 'onActiveCellChange', builtin: false, desc: 'The active cell changes.', params: 'cell: { rowIndex: number; colIndex: number; columnId: string }' },
487
+ { key: 'cellValueChange', method: 'onCellValueChange', prop: 'onCellValueChange', builtin: true, desc: 'An inline cell edit is committed.', params: 'e: { rowIndex: number; columnId: string; oldValue: unknown; newValue: unknown; row: Row }' },
488
+ { key: 'sortingChange', method: 'onSortingChange', prop: 'onSortingChange', builtin: true, desc: 'The sort clauses change.', params: 'sorting: Array<{ id: string; desc: boolean }>' },
489
+ { key: 'filtersChange', method: 'onFiltersChange', prop: 'onFiltersChange', builtin: true, desc: 'Any in-grid filter changes.', params: 'filters: { global: string; columns: Array<{ id: string; operator: string; value: string; valueTo?: string; selectedValues?: string[] }> }' },
490
+ { key: 'paginationChange', method: 'onPaginationChange', prop: 'onPaginationChange', builtin: true, desc: 'The page or page size changes.', params: 'pagination: { pageIndex: number; pageSize: number }' },
491
+ { key: 'columnOrderChange', method: 'onColumnOrderChange', prop: 'onColumnOrderChange', builtin: false, desc: 'The column order changes.', params: 'order: ReadonlyArray<string>' },
492
+ { key: 'scrollBottomReached', method: 'onScrollBottomReached', prop: 'onScrollBottomReached', builtin: false, desc: 'The body scrolls near the bottom (lazy-load hook).', params: 'e: { scrollTop: number; scrollHeight: number; clientHeight: number }' },
493
+ { key: 'noteChange', method: 'onNoteChange', prop: 'onNoteChange', builtin: false, desc: 'A cell note/comment is saved or removed (needs editable comments).', params: 'e: { rowId: string; columnId: string; note: string }' },
494
+ { key: 'rowDragEnd', method: 'onRowDragEnd', prop: 'onRowDragEnd', builtin: false, desc: 'A managed row drag settles (needs row dragging enabled).', params: 'e: { row: Row; toIndex: number; sameGrid: boolean; fromGridId: number; toGridId: number }' },
495
+ // Data-layer events - fired by the screen's data controller after a write settles
496
+ // (form save, inline edit, delete action, or ctx.data.create/update/delete), not by
497
+ // the grid markup. Only fire on entity screens (which have a data source).
498
+ { key: 'rowAdded', method: 'onRowAdded', prop: '', builtin: false, dataEvent: true, desc: 'A row was created (form / ctx.data.create).', params: 'row: Row' },
499
+ { key: 'rowUpdated', method: 'onRowUpdated', prop: '', builtin: false, dataEvent: true, desc: 'A row was updated (form / inline edit / ctx.data.update).', params: 'row: Row' },
500
+ { key: 'rowDeleted', method: 'onRowDeleted', prop: '', builtin: false, dataEvent: true, desc: 'A row was deleted (delete action / ctx.data.delete).', params: 'id: string' },
501
+ ]
502
+
415
503
  // --- logic core: screen state + a small expression engine --------------------
416
504
 
417
505
  /** A screen-scoped reactive variable (`ctx.state.<name>`). Emitted as `$state`. */
@@ -644,7 +732,7 @@ export function setLayoutOpts<K extends keyof LayoutOpts>(project: StudioProject
644
732
  return mapScreen(project, screenId, (s) => ({ ...s, layoutOpts: { ...s.layoutOpts, [mode]: { ...s.layoutOpts?.[mode], ...patch } } }))
645
733
  }
646
734
 
647
- export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[] }
735
+ export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[]; renderMode?: 'ssr' | 'spa' }
648
736
 
649
737
  /** The generated app's shell (master layout): sidebar, top-nav, or bottom-nav; brand, footer. */
650
738
  export type ShellStyle = 'sidebar' | 'top-nav' | 'bottom-nav'
@@ -680,6 +768,13 @@ export type AccessControl = {
680
768
  export type OAuthProvider = 'github' | 'google' | 'oidc'
681
769
  export type AuthConfig = {
682
770
  enabled: boolean
771
+ /** Which sign-in system to scaffold. `'builtin'` (default) is the dependency-free
772
+ * cookie-session starter below (own user store, server route guards, RBAC).
773
+ * `'supabase'` delegates to Supabase Auth: a client-side `SvAuthGate` over the
774
+ * shared Supabase client, pairing with the database's Row Level Security. The
775
+ * builtin-only sub-options (register / userAdmin / oauth / twoFactor / email) do
776
+ * not apply to the Supabase provider. */
777
+ provider?: 'builtin' | 'supabase'
683
778
  /** Redirect unauthenticated visitors to /login for every route. Default true. */
684
779
  protect?: boolean
685
780
  /** Self-service sign-up + password recovery (/register, /forgot-password,
@@ -711,6 +806,10 @@ export type StudioProject = {
711
806
  dataSource: DataSourceKind
712
807
  /** Per-entity data-source binding, keyed by entity name. */
713
808
  dataSources?: Record<string, EntityDataSource>
809
+ /** Project-level Supabase connection (URL + anon key), shared by every
810
+ * Supabase-bound entity so it's set once. A per-entity `SupabaseSource.url`/`key`
811
+ * still overrides for that entity. Read by the wizard + the `.env` paste hint. */
812
+ supabase?: { url?: string; key?: string }
714
813
  theme?: ProjectTheme
715
814
  /** Role-based access control (optional; off unless `access.enabled`). */
716
815
  access?: AccessControl
@@ -731,6 +830,117 @@ export type StudioProject = {
731
830
  /** Server-side business-rule triggers, keyed by entity name. Enforced on the
732
831
  * SQL route (compiled into createKitHandlers `hooks`). */
733
832
  triggers?: Record<string, EntityTriggers>
833
+ /** Scheduled background jobs: a guarded `/api/cron` endpoint plus the schedule
834
+ * config for the deploy target. Empty / omitted emits nothing. */
835
+ jobs?: ScheduledJob[]
836
+ /** Multi-tenancy: scope every row to the signed-in user's tenant. */
837
+ tenancy?: TenancyConfig
838
+ }
839
+
840
+ /**
841
+ * Multi-tenancy for the generated app: one database, one deployment, rows
842
+ * partitioned by a tenant column.
843
+ *
844
+ * Enforced on the SERVER, in the API route's `scope` option - reads are
845
+ * filtered, creates are stamped, and updates/deletes re-read the target under
846
+ * the scope first. A client that skips the UI still cannot reach another
847
+ * tenant's rows.
848
+ *
849
+ * Requires the [auth starter](./auth.md) (the tenant comes from the session)
850
+ * and the typed data layer (the column has to exist in the schema). Without
851
+ * both it degrades to off rather than emitting a half-enforced scope.
852
+ */
853
+ export type TenancyConfig = {
854
+ enabled: boolean
855
+ /** Column holding the tenant key on every scoped entity. Default `tenantId`. */
856
+ field?: string
857
+ /** Entities to leave GLOBAL (shared across tenants) - reference/lookup tables
858
+ * like currencies or countries that every tenant reads. */
859
+ sharedEntities?: string[]
860
+ }
861
+
862
+ /** What a scheduled job does when it fires.
863
+ * - `email`: send a summary of an entity (row count + the newest rows).
864
+ * - `code`: run a body you write, with `db` / `sendEmail` in scope. */
865
+ export type ScheduledJobKind = 'email' | 'code'
866
+
867
+ /**
868
+ * One scheduled background job.
869
+ *
870
+ * Jobs run on the SERVER via a generated `/api/cron` route, not in a browser
871
+ * tab - unlike the client-side `createScheduler` in `@svgrid/enterprise`, which
872
+ * only fires while the app is open. The route is secret-guarded and invoked by
873
+ * the platform's scheduler (Vercel Cron, a GitHub Actions schedule, or your own
874
+ * crontab hitting the URL).
875
+ */
876
+ export type ScheduledJob = {
877
+ /** Stable id - the `?job=` selector and the handler name. */
878
+ id: string
879
+ /** Human label used in comments + the DEPLOY.md table. */
880
+ name: string
881
+ /** Standard 5-field cron expression, interpreted in UTC. */
882
+ cron: string
883
+ /** Off by default is surprising for something you explicitly added, so a job
884
+ * runs unless `enabled` is explicitly false. */
885
+ enabled?: boolean
886
+ kind: ScheduledJobKind
887
+ /** `email`: the entity to summarize. */
888
+ entity?: string
889
+ /** `email`: recipient address. */
890
+ to?: string
891
+ /** `email`: subject line. Defaults to the job name. */
892
+ subject?: string
893
+ /** `code`: the handler body (TypeScript). */
894
+ code?: string
895
+ }
896
+
897
+ /** The render mode for a screen. `'ssr'` emits idiomatic SvelteKit (`+page.server.ts`
898
+ * with a `load` + form `actions`, SSR + progressive enhancement); `'spa'` (the
899
+ * default) emits the client data-source-controller page. Opt-in for now. */
900
+ export function screenRenderMode(_project: StudioProject, screen: Screen): 'ssr' | 'spa' {
901
+ return screen.renderMode === 'ssr' ? 'ssr' : 'spa'
902
+ }
903
+
904
+ /** Block kinds a read-only SSR screen can host: pure renders over server-loaded
905
+ * rows. Board/calendar/scheduler (client interaction runtimes), components
906
+ * (client bindings), grids-with-extras, and containers stay SPA. */
907
+ const SSR_READ_KINDS = new Set<BlockKind>(['chart', 'pivot', 'dashboard', 'kpi', 'gauge', 'tree', 'detail', 'master-detail'])
908
+
909
+ /** How a screen would emit under SSR: 'grid' (single grid -> load + form actions,
910
+ * URL-driven sort/filter/page), 'read' (data-viz/detail blocks -> load only), or
911
+ * null when it must stay SPA. */
912
+ export function ssrScreenShape(project: StudioProject, screen: Screen): 'grid' | 'read' | null {
913
+ if (!screen.entity || screen.code) return null
914
+ const kind = project.dataSources?.[screen.entity]?.kind ?? project.dataSource
915
+ // memory runs the source in-process; sql reuses the connected /api route via
916
+ // event.fetch. (rest/supabase/pglite stay SPA for now.)
917
+ if (kind !== 'memory' && kind !== 'sql') return null
918
+ const blocks = screen.blocks ?? []
919
+ if (blocks.length === 1 && blocks[0]!.config.kind === 'grid') {
920
+ const g = blocks[0]!.config as GridConfig
921
+ if (g.treeData || g.scheduler) return null
922
+ return 'grid'
923
+ }
924
+ if (blocks.length >= 1 && blocks.every((b) => SSR_READ_KINDS.has(b.config.kind))) return 'read'
925
+ return null
926
+ }
927
+
928
+ /** Whether a screen can be emitted as SSR-native (any supported shape). */
929
+ export function ssrEligible(project: StudioProject, screen: Screen): boolean {
930
+ return ssrScreenShape(project, screen) !== null
931
+ }
932
+
933
+ /** Set a screen's render mode ('spa' clears back to the default). */
934
+ export function setScreenRenderMode(project: StudioProject, screenId: string, mode: 'ssr' | 'spa'): StudioProject {
935
+ return {
936
+ ...project,
937
+ screens: project.screens.map((s) => (s.id === screenId ? { ...s, renderMode: mode === 'ssr' ? 'ssr' : undefined } : s)),
938
+ }
939
+ }
940
+
941
+ /** True when the screen should actually emit as SSR (mode is 'ssr' AND eligible). */
942
+ export function isSsrScreen(project: StudioProject, screen: Screen): boolean {
943
+ return screenRenderMode(project, screen) === 'ssr' && ssrEligible(project, screen)
734
944
  }
735
945
 
736
946
  /** The triggers configured for an entity (or an empty object). */
@@ -749,6 +959,32 @@ export function setTrigger(project: StudioProject, entity: string, event: Trigge
749
959
  return { ...project, triggers: Object.keys(triggers).length ? triggers : undefined }
750
960
  }
751
961
 
962
+ /** Turn multi-tenancy on/off and configure the scoping column. */
963
+ export function setTenancy(project: StudioProject, tenancy: TenancyConfig | null): StudioProject {
964
+ if (!tenancy || !tenancy.enabled) {
965
+ const { tenancy: _drop, ...rest } = project
966
+ return rest
967
+ }
968
+ return { ...project, tenancy }
969
+ }
970
+
971
+ /** The tenant column name, defaulted. */
972
+ export const tenantField = (project: StudioProject): string => project.tenancy?.field || 'tenantId'
973
+
974
+ /** True when `entity` is scoped to a tenant (i.e. tenancy is on and it is not
975
+ * listed as shared). Shared entities stay global reference data. */
976
+ export function isTenantScoped(project: StudioProject, entity: string): boolean {
977
+ if (project.tenancy?.enabled !== true) return false
978
+ return !(project.tenancy.sharedEntities ?? []).includes(entity)
979
+ }
980
+
981
+ /** Add or replace a scheduled job (matched by `id`). Pass `null` to remove one. */
982
+ export function setJob(project: StudioProject, id: string, job: Omit<ScheduledJob, 'id'> | null): StudioProject {
983
+ const rest = (project.jobs ?? []).filter((j) => j.id !== id)
984
+ const next = job ? [...rest, { ...job, id }] : rest
985
+ return { ...project, jobs: next.length ? next : undefined }
986
+ }
987
+
752
988
  /** Where the generated app deploys. Drives the emitted SvelteKit adapter + config. */
753
989
  export type DeployTarget = 'auto' | 'vercel' | 'netlify' | 'cloudflare' | 'node'
754
990
 
@@ -1243,7 +1479,13 @@ export function removeScreen(project: StudioProject, screenId: string): StudioPr
1243
1479
  return { ...project, screens: project.screens.filter((s) => s.id !== screenId) }
1244
1480
  }
1245
1481
 
1246
- export function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className'>>): StudioProject {
1482
+ export function updateScreen(
1483
+ project: StudioProject,
1484
+ screenId: string,
1485
+ // `renderMode` is patchable here so SSR output is reachable without the
1486
+ // designer - the CLI and the MCP tools drive the model through this function.
1487
+ patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className' | 'renderMode'>>,
1488
+ ): StudioProject {
1247
1489
  return mapScreen(project, screenId, (s) => ({ ...s, ...patch }))
1248
1490
  }
1249
1491
 
@@ -1761,10 +2003,12 @@ export function setAuth(project: StudioProject, patch: Partial<AuthConfig> & { e
1761
2003
  if (!patch.enabled) { const { auth: _drop, ...rest } = project; return rest }
1762
2004
  const prev = project.auth
1763
2005
  const oauth = patch.oauth ?? prev?.oauth
2006
+ const provider = patch.provider ?? prev?.provider
1764
2007
  return {
1765
2008
  ...project,
1766
2009
  auth: {
1767
2010
  enabled: true,
2011
+ ...(provider && provider !== 'builtin' ? { provider } : {}),
1768
2012
  protect: patch.protect ?? prev?.protect ?? true,
1769
2013
  register: patch.register ?? prev?.register ?? false,
1770
2014
  userAdmin: patch.userAdmin ?? prev?.userAdmin ?? false,
@@ -1812,9 +2056,11 @@ export function setEntityDataSource(project: StudioProject, entityName: string,
1812
2056
  return { ...project, dataSources: { ...project.dataSources, [entityName]: source } }
1813
2057
  }
1814
2058
 
1815
- /** The resolved source for an entity (its explicit binding, else in-memory). */
2059
+ /** The resolved source for an entity: its explicit per-entity binding, else a skeleton
2060
+ * for the project's default source kind (so the rail "Default source" actually applies
2061
+ * to unbound entities, and this agrees with `ssrScreenShape`'s fallback). */
1816
2062
  export function entityDataSource(project: StudioProject, entityName: string): EntityDataSource {
1817
- return project.dataSources?.[entityName] ?? { kind: 'memory' }
2063
+ return project.dataSources?.[entityName] ?? defaultEntitySource(project.dataSource ?? 'memory', entityName)
1818
2064
  }
1819
2065
 
1820
2066
  export function setTheme(project: StudioProject, theme: ProjectTheme): StudioProject {
@@ -1972,6 +2218,7 @@ export function parseProject(json: string): StudioProject {
1972
2218
  screens: p.screens as Screen[],
1973
2219
  dataSource: (p.dataSource ?? 'memory') as DataSourceKind,
1974
2220
  ...(p.dataSources && typeof p.dataSources === 'object' ? { dataSources: p.dataSources as Record<string, EntityDataSource> } : {}),
2221
+ ...(p.supabase && typeof p.supabase === 'object' ? { supabase: p.supabase as { url?: string; key?: string } } : {}),
1975
2222
  ...(p.theme && typeof p.theme === 'object' ? { theme: p.theme as ProjectTheme } : {}),
1976
2223
  ...(p.access && typeof p.access === 'object' ? { access: p.access as AccessControl } : {}),
1977
2224
  ...(p.auth && typeof p.auth === 'object' && (p.auth as AuthConfig).enabled ? { auth: p.auth as AuthConfig } : {}),
@@ -2028,6 +2275,53 @@ export function validateProject(project: StudioProject): ProjectIssue[] {
2028
2275
  }
2029
2276
  }
2030
2277
  }
2278
+
2279
+ // Project-level deployment footguns (all advisory - the app still generates).
2280
+ const sources = Object.values(project.dataSources ?? {})
2281
+ const sqlSources = sources.filter((s): s is Extract<EntityDataSource, { kind: 'sql' }> => s.kind === 'sql')
2282
+ if (sqlSources.length > 0 && project.dataLayer !== 'drizzle') {
2283
+ issues.push({
2284
+ level: 'warning',
2285
+ message:
2286
+ 'SQL entities have no migrations - their tables must already exist in the database. ' +
2287
+ 'Enable the Drizzle data layer for typed migrations, or run the generated db/schema.sql against your database first.',
2288
+ })
2289
+ }
2290
+ if (project.deploy === 'cloudflare' && sqlSources.some((s) => s.dialect === 'postgres' || s.dialect === 'supabase')) {
2291
+ issues.push({
2292
+ level: 'warning',
2293
+ message:
2294
+ 'Cloudflare Workers cannot run the socket "pg" Postgres driver. Swap the generated route to an HTTP driver ' +
2295
+ '(e.g. Neon serverless) or choose another deploy target.',
2296
+ })
2297
+ }
2298
+ for (const [name, src] of Object.entries(project.dataSources ?? {})) {
2299
+ if (src.kind === 'supabase' && !(src.url ?? project.supabase?.url) && !(src.key ?? project.supabase?.key)) {
2300
+ issues.push({
2301
+ level: 'warning',
2302
+ message: `Supabase entity "${name}" has no URL/key - set the shared project connection (or this entity's own), or the generated app just reads PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY from .env.`,
2303
+ })
2304
+ }
2305
+ }
2306
+ // Supabase Auth needs a Supabase client to authenticate against: either the shared
2307
+ // project connection or at least one Supabase-bound entity (both emit connections.ts).
2308
+ if (project.auth?.enabled && project.auth.provider === 'supabase') {
2309
+ const hasSupabase = !!project.supabase?.url || sources.some((s) => s.kind === 'supabase')
2310
+ if (!hasSupabase) {
2311
+ issues.push({
2312
+ level: 'warning',
2313
+ message: 'Supabase Auth needs a Supabase connection. Set the shared project URL / anon key (in a data-source builder), or bind an entity to Supabase.',
2314
+ })
2315
+ }
2316
+ // Supabase Auth is a client-side gate; it does not populate the server-side role
2317
+ // the RBAC route guard reads. Server enforcement must come from RLS instead.
2318
+ if (project.access?.enabled) {
2319
+ issues.push({
2320
+ level: 'warning',
2321
+ message: 'Supabase Auth signs in on the client, so it does not populate the server-side role that RBAC route guards check. Enforce per-user access with Row Level Security policies on your Supabase tables.',
2322
+ })
2323
+ }
2324
+ }
2031
2325
  return issues
2032
2326
  }
2033
2327