@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
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { compile } from 'svelte/compiler'
3
3
  import type { EntitySchema } from '../schema'
4
- import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts, setScreenDock, setDockPaneTitle, dockPaneTitleOf, syncDockPanes, dockPaneIds, removeBlock, setAuth, setComponentBinding, setDataLayer, setDeployTarget, setEntityDataSource, setTrigger, setHandlerBody, setHandlerSteps, stepsToCode, clickSlot, setScreenHandlersSource, setScreenRenderGrid, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type AccordionConfig, type StudioProject } from './project'
4
+ import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts, setDockPaneTitle, dockPaneTitleOf, syncDockPanes, dockPaneIds, removeBlock, setAuth, setComponentBinding, setDataLayer, setDeployTarget, setEntityDataSource, setJob, setTenancy, setTrigger, setHandlerBody, setHandlerSteps, stepsToCode, clickSlot, setScreenHandlersSource, setScreenRenderGrid, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type AccordionConfig, type StudioProject } from './project'
5
5
  import ts from 'typescript'
6
- import { emitStudioProject, emitStudioAppBundle, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
6
+ import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
7
7
  import { UI_COMPONENT_REGISTRY } from './ui-components'
8
8
 
9
9
  /** Type-check a handler BODY against a generated ambient ctx `.d.ts` using the real
@@ -48,6 +48,340 @@ const orders: EntitySchema = {
48
48
  ],
49
49
  }
50
50
 
51
+ describe('SSR-native screens (renderMode: ssr)', () => {
52
+ const ssrProject = (): StudioProject => {
53
+ const p = createProject([customers])
54
+ return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
55
+ }
56
+
57
+ it('updateScreen can switch a screen to SSR (the CLI / MCP path, not just the designer)', () => {
58
+ // The other tests in this block set renderMode by spreading the screen object.
59
+ // Going through updateScreen is what the CLI and the studio_update_screen MCP
60
+ // tool do - and its patch type has to allow renderMode for that to be possible.
61
+ const p0 = createProject([customers])
62
+ const sid = p0.screens[0]!.id
63
+ expect(emitStudioProject(p0).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
64
+
65
+ const p1 = updateScreen(p0, sid, { renderMode: 'ssr' })
66
+ expect(p1.screens[0]!.renderMode).toBe('ssr')
67
+ const server = emitStudioProject(p1).find((f) => f.path === 'src/routes/customers/+page.server.ts')
68
+ expect(server, 'updateScreen({ renderMode: "ssr" }) should reach the SSR emitter').toBeTruthy()
69
+ expect(server!.contents).toContain('export const load')
70
+ expect(server!.contents).toContain('export const actions')
71
+
72
+ // And back again - the SPA page returns, the server page goes away.
73
+ const p2 = updateScreen(p1, sid, { renderMode: 'spa' })
74
+ expect(emitStudioProject(p2).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
75
+ })
76
+
77
+ it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
78
+ const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
79
+ expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
80
+ const c = server!.contents
81
+ expect(c).toMatch(/export const ssr = true/) // opts back into SSR over the SPA layout's ssr=false
82
+ expect(c).toMatch(/export const load: PageServerLoad/)
83
+ expect(c).toMatch(/export const actions: Actions/)
84
+ expect(c).toMatch(/create:\s*async/)
85
+ expect(c).toMatch(/update:\s*async/)
86
+ expect(c).toMatch(/delete:\s*async/)
87
+ expect(c).toMatch(/await validateAll\(customersSchema, values\)/) // server-side validation
88
+ expect(c).toMatch(/return fail\(422/)
89
+ expect(c).toMatch(/planFromSearchParams\(url/)
90
+ expect(c).toMatch(/await customersSource\.getRows\(plan\)/)
91
+ })
92
+
93
+ it('emits an SSR +page.svelte: server rows, URL-driven sort/page, form-action editing', () => {
94
+ const page = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
95
+ expect(page).toMatch(/import type \{ PageProps \} from '\.\/\$types'/)
96
+ expect(page).toMatch(/data=\{data\.rows\}/) // renders SSR'd rows
97
+ expect(page).toMatch(/externalSort/)
98
+ expect(page).toMatch(/initialSorting=\{data\.sort\}/) // header indicator matches the URL sort on hard reload
99
+ expect(page).toMatch(/externalPagination/)
100
+ expect(page).toMatch(/onSortingChange=/)
101
+ expect(page).toMatch(/use:enhance=\{onSubmit\}/) // progressive enhancement
102
+ expect(page).toMatch(/action=\{isCreate \? '\?\/create' : '\?\/update'\}/)
103
+ // It must NOT fall back to the client data-source controller.
104
+ expect(page).not.toMatch(/createServerDataSource/)
105
+ // Valid Svelte 5.
106
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
107
+ })
108
+
109
+ it('SSR grid honors filterable: URL-driven filter (q + f_<col>) via applyFilters; off when not filterable', () => {
110
+ const p0 = createProject([customers])
111
+ const withFilter: StudioProject = {
112
+ ...p0,
113
+ screens: p0.screens.map((s) => ({
114
+ ...s,
115
+ renderMode: 'ssr' as const,
116
+ blocks: s.blocks.map((b) => (b.config.kind === 'grid' ? { ...b, config: { ...b.config, filterable: true } } : b)),
117
+ })),
118
+ }
119
+ const page = emitStudioProject(withFilter).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
120
+ expect(page).toMatch(/externalFilter/)
121
+ expect(page).toMatch(/onFiltersChange=\{applyFilters\}/)
122
+ expect(page).toMatch(/function applyFilters/)
123
+ expect(page).toMatch(/sp\.set\('f_' \+ c\.id, c\.value\)/) // per-column f_ params
124
+ // The default grid (filterable: false) omits the whole filter surface.
125
+ const off = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
126
+ expect(off).not.toMatch(/externalFilter/)
127
+ expect(off).not.toMatch(/function applyFilters/)
128
+ })
129
+
130
+ it('emits the shared server query helper once', () => {
131
+ const files = emitStudioProject(ssrProject())
132
+ const helpers = files.filter((f) => f.path === 'src/lib/server/query.ts')
133
+ expect(helpers).toHaveLength(1)
134
+ expect(helpers[0]!.contents).toMatch(/export function planFromSearchParams/)
135
+ })
136
+
137
+ it('sql SSR screen: load/actions proxy to the connected /api route via createKitDataSource', () => {
138
+ const p0 = createProject([customers])
139
+ const p: StudioProject = {
140
+ ...p0,
141
+ dataSource: 'sql',
142
+ dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' } },
143
+ screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
144
+ }
145
+ const files = emitStudioProject(p)
146
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
147
+ expect(server).toMatch(/import \{ validateAll, createKitDataSource \} from '@svgrid\/enterprise'/)
148
+ expect(server).toMatch(/createKitDataSource<Customers>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
149
+ expect(server).toMatch(/load: PageServerLoad = async \(\{ url, fetch \}\)/) // load gets event.fetch
150
+ expect(server).not.toMatch(/from '\$lib\/data'/) // no in-process source import for sql
151
+ // The connected /api route is still emitted - enforcement (validate/RBAC/triggers/audit) lives there.
152
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeTruthy()
153
+ // Page still compiles.
154
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
155
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
156
+ })
157
+
158
+ it('read-only SSR screens (dashboard/kpi): load-only server file + page renders from data.*', () => {
159
+ let p = createProject([customers])
160
+ const sid = p.screens[0]!.id
161
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
162
+ p = addBlock(p, sid, 'kpi')
163
+ p = addBlock(p, sid, 'chart')
164
+ const files = emitStudioProject(p)
165
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
166
+ expect(server).toMatch(/export const ssr = true/)
167
+ expect(server).toMatch(/export const load: PageServerLoad/)
168
+ expect(server).toMatch(/const rows = \(await customersSource\.getRows\(PAGE\)\)\.rows/)
169
+ expect(server).not.toMatch(/export const actions/) // read-only
170
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
171
+ expect(page).toMatch(/let \{ data \}: PageProps = \$props\(\)/)
172
+ expect(page).toMatch(/const allRows = \$derived\(data\.rows as Customers\[\]\)/)
173
+ expect(page).not.toMatch(/loadAll\(\)/)
174
+ expect(page).not.toMatch(/from '\$lib\/data'/) // all data comes from the server load
175
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
176
+ })
177
+
178
+ it('read-only SSR + master-detail: child collections load server-side too (sql via /api)', () => {
179
+ const p0 = createProject([customers, orders])
180
+ let p: StudioProject = {
181
+ ...p0,
182
+ dataSource: 'sql',
183
+ dataSources: {
184
+ customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
185
+ orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
186
+ },
187
+ }
188
+ const sid = p.screens[0]!.id
189
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
190
+ p = addBlock(p, sid, 'master-detail')
191
+ const mdId = p.screens[0]!.blocks.at(-1)!.id
192
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
193
+ const files = emitStudioProject(p)
194
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
195
+ expect(server).toMatch(/createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
196
+ expect(server).toMatch(/const md_orders_rows = \(await createKitDataSource.*'\/api\/orders'/)
197
+ expect(server).toMatch(/return \{ rows, md_orders_rows \}/)
198
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
199
+ expect(page).toMatch(/const md_orders_rows = \$derived\(data\.md_orders_rows as Orders\[\]\)/)
200
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
201
+ })
202
+
203
+ it('a board screen stays SPA even with renderMode ssr (client-only interaction)', () => {
204
+ let p = createProject([customers])
205
+ const sid = p.screens[0]!.id
206
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
207
+ p = addBlock(p, sid, 'board')
208
+ const files = emitStudioProject(p)
209
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
210
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents).toMatch(/loadAll\(\)/)
211
+ })
212
+
213
+ it('sql SSR + relation field: prefetches options via the related /api route', () => {
214
+ const p0 = createProject([customers, orders])
215
+ const p: StudioProject = {
216
+ ...p0,
217
+ dataSource: 'sql',
218
+ dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' }, orders: { kind: 'sql', dialect: 'postgres', table: 'orders' } },
219
+ screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)),
220
+ }
221
+ const server = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
222
+ expect(server).toMatch(/const customer_idOptions = \(await createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)\.getRows\(/)
223
+ expect(server).toMatch(/, customer_idOptions \}/)
224
+ expect(server).not.toMatch(/from '\$lib\/data'/) // sql uses the /api route, not the in-process source
225
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
226
+ expect(page).toMatch(/<select name='customer_id'/)
227
+ expect(page).toMatch(/#each data\.customer_idOptions as o/)
228
+ })
229
+
230
+ it('memory SSR + RBAC: load/actions enforce authz inline (sql inherits from /api)', () => {
231
+ const p0 = createProject([customers])
232
+ const p: StudioProject = {
233
+ ...p0,
234
+ access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: p0.screens.map((s) => s.id), actions: [] }] },
235
+ screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
236
+ }
237
+ const server = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
238
+ expect(server).toMatch(/import \{ fail, error \} from '@sveltejs\/kit'/)
239
+ expect(server).toMatch(/import \{ authorizeAction, getServerRole \} from '\$lib\/access'/)
240
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'read', SCREEN_IDS\)/)
241
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'create', SCREEN_IDS\)/)
242
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'delete', SCREEN_IDS\)/)
243
+ })
244
+
245
+ it('memory SSR + relation field: prefetches options in load and renders a <select>', () => {
246
+ const p0 = createProject([customers, orders])
247
+ const p: StudioProject = { ...p0, screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)) }
248
+ const files = emitStudioProject(p)
249
+ const server = files.find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
250
+ expect(server).toMatch(/import \{ ordersSource, customersSource, nextId \} from '\$lib\/data'/)
251
+ expect(server).toMatch(/const customer_idOptions = \(await customersSource\.getRows\(/)
252
+ expect(server).toMatch(/label: String\(r\['name'\]/) // uses the relation's labelField
253
+ expect(server).toMatch(/, customer_idOptions \}/) // options returned from load
254
+ const page = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
255
+ expect(page).toMatch(/<select name='customer_id'/)
256
+ expect(page).toMatch(/#each data\.customer_idOptions as o/)
257
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
258
+ })
259
+
260
+ it('leaves spa screens on the client-controller path', () => {
261
+ const p = createProject([customers]) // default renderMode is spa
262
+ const files = emitStudioProject(p)
263
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
264
+ expect(files.find((f) => f.path === 'src/lib/server/query.ts')).toBeUndefined()
265
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
266
+ expect(page).toMatch(/createServerDataSource/)
267
+ })
268
+ })
269
+
270
+ describe('emitStudioFragment (drop into an existing app)', () => {
271
+ it('emits app content minus the shell, plus app.css + FRAGMENT.md', () => {
272
+ const files = emitStudioFragment(createProject([customers]))
273
+ const paths = files.map((f) => f.path)
274
+ // No whole-app scaffolding.
275
+ expect(paths).not.toContain('package.json')
276
+ expect(paths).not.toContain('svelte.config.js')
277
+ expect(paths).not.toContain('src/routes/+layout.svelte') // the nav shell
278
+ expect(paths).not.toContain('src/routes/+page.svelte') // the home redirect
279
+ // The screens + lib + the lifted styles + the readme ARE present.
280
+ expect(paths).toContain('src/routes/customers/+page.svelte')
281
+ expect(paths).toContain('src/lib/schemas.ts')
282
+ expect(paths).toContain('src/lib/data.ts')
283
+ expect(paths).toContain('src/app.css')
284
+ expect(paths).toContain('FRAGMENT.md')
285
+ })
286
+
287
+ it('FRAGMENT.md reports the runtime deps + host requirements', () => {
288
+ let p = createProject([customers])
289
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
290
+ const md = emitStudioFragment(p).find((f) => f.path === 'FRAGMENT.md')!.contents
291
+ expect(md).toMatch(/npm install .*\bpg\b/) // the sql driver the fragment imports
292
+ expect(md).toMatch(/DATABASE_URL/) // env the host must set
293
+ expect(md).toMatch(/Import `src\/app\.css`/)
294
+ expect(md).toMatch(/nav shell.*NOT here/s)
295
+ })
296
+ })
297
+
298
+ describe('db/schema.sql + db/seed.sql + drizzle db:seed (real-DB last mile)', () => {
299
+ const sqlProject = (dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'turso'): StudioProject => {
300
+ let p = createProject([customers, orders])
301
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
302
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect })
303
+ return p
304
+ }
305
+
306
+ it('emits db/schema.sql + db/seed.sql for SQL entities without the Drizzle layer', () => {
307
+ const files = emitStudioProject(sqlProject('postgres'))
308
+ const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
309
+ expect(ddl).toMatch(/CREATE TABLE IF NOT EXISTS "customers" \(/)
310
+ expect(ddl).toMatch(/"id" text PRIMARY KEY/)
311
+ expect(ddl).toMatch(/"mrr" double precision/)
312
+ // Relation display fields are runtime labels, not columns.
313
+ const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
314
+ expect(seedSql).toMatch(/INSERT INTO "customers" \(/)
315
+ expect(seedSql).toMatch(/INSERT INTO "orders" \(/)
316
+ })
317
+
318
+ it('mssql gets a guarded CREATE TABLE (no Drizzle possible) with N-literals', () => {
319
+ const files = emitStudioProject(sqlProject('mssql'))
320
+ const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
321
+ expect(ddl).toMatch(/IF OBJECT_ID\(N'customers', N'U'\) IS NULL/)
322
+ expect(ddl).toMatch(/"mrr" float/)
323
+ const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
324
+ expect(seedSql).toMatch(/N'/)
325
+ // Even WITH the drizzle toggle, mssql still ships plain SQL (dzDialect is null).
326
+ const withLayer = emitStudioProject(setDataLayer(sqlProject('mssql'), true))
327
+ expect(withLayer.some((f) => f.path === 'db/schema.sql')).toBe(true)
328
+ })
329
+
330
+ it('mysql uses backticks; sqlite/turso use sqlite types', () => {
331
+ const my = emitStudioProject(sqlProject('mysql')).find((f) => f.path === 'db/schema.sql')!.contents
332
+ expect(my).toMatch(/CREATE TABLE IF NOT EXISTS `customers` \(/)
333
+ expect(my).toMatch(/`mrr` double/)
334
+ const tu = emitStudioProject(sqlProject('turso')).find((f) => f.path === 'db/schema.sql')!.contents
335
+ expect(tu).toMatch(/"mrr" real/)
336
+ })
337
+
338
+ it('the Drizzle layer replaces plain DDL with migrations + a db:seed script', () => {
339
+ const p = setDataLayer(sqlProject('postgres'), true)
340
+ const files = emitStudioAppBundle(p)
341
+ expect(files.some((f) => f.path === 'db/schema.sql')).toBe(false) // migrations own DDL
342
+ const seedTs = files.find((f) => f.path === 'db/seed.ts')!.contents
343
+ expect(seedTs).toMatch(/studio:db-seed/)
344
+ expect(seedTs).toMatch(/process\.env\.DATABASE_URL/) // own client - no \$env import
345
+ expect(seedTs).not.toMatch(/\$env\/dynamic\/private/)
346
+ expect(seedTs).toMatch(/db\.insert\(schema\.customers\)/)
347
+ expect(seedTs).toMatch(/existing\.length === 0/) // idempotent
348
+ const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
349
+ expect(pkg.scripts['db:seed']).toBe('tsx db/seed.ts')
350
+ expect(pkg.devDependencies.tsx).toBeTruthy()
351
+ const deploy = files.find((f) => f.path === 'DEPLOY.md')!.contents
352
+ expect(deploy).toMatch(/npm run db:seed/)
353
+ })
354
+
355
+ it('DEPLOY.md points at db/schema.sql on the non-Drizzle path', () => {
356
+ const deploy = emitStudioAppBundle(sqlProject('postgres')).find((f) => f.path === 'DEPLOY.md')!.contents
357
+ expect(deploy).toMatch(/db\/schema\.sql/)
358
+ expect(deploy).toMatch(/VITE_SVPRO_KEY/)
359
+ })
360
+ })
361
+
362
+ describe('secure-by-default auth', () => {
363
+ const authApp = () => emitStudioAppBundle(setAuth(createProject([customers]), { enabled: true, protect: true }))
364
+
365
+ it('ships a git-ignored .env with a real random SESSION_SECRET (not the hardcoded fallback)', () => {
366
+ const env = authApp().find((f) => f.path === '.env')
367
+ expect(env, 'an auth app should ship a .env with a real secret').toBeTruthy()
368
+ expect(env!.contents).toMatch(/SESSION_SECRET=[0-9a-f]{64}/)
369
+ expect(env!.contents).not.toMatch(/change-me-to-a-long-random-string/)
370
+ })
371
+
372
+ it('hashes demo users - no plaintext store or plaintext login compare', () => {
373
+ const files = authApp()
374
+ const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
375
+ expect(users).toMatch(/passwordHash: await hashPassword/)
376
+ // The stored user type carries only the hash (the SEED keeps plaintext defaults,
377
+ // which are hashed at module load).
378
+ expect(users).toMatch(/AppUser = \{ email: string; name: string; role: string; passwordHash: string \}/)
379
+ const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
380
+ expect(login).toMatch(/await verifyPassword\(password, user\.passwordHash\)/)
381
+ expect(login).not.toMatch(/user\.password !== password/)
382
+ })
383
+ })
384
+
51
385
  describe('emitStudioProject (per-block screens)', () => {
52
386
  const project = createProject([customers, orders])
53
387
  const files = emitStudioProject(project)
@@ -408,7 +742,7 @@ describe('emitStudioProject (per-block screens)', () => {
408
742
  expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
409
743
  // The shell wires the session in: login bypass, real user, sign-out, role seed.
410
744
  const layout = get('src/routes/+layout.svelte')!
411
- expect(layout).toContain('["/login"].includes($page.url.pathname)') // login renders bare (no shell)
745
+ expect(layout).toContain('["/login"].includes(page.url.pathname)') // login renders bare (no shell)
412
746
  expect(layout).toContain('action="/logout"')
413
747
  expect(layout).toContain('currentRole.set(data.role')
414
748
  expect(layout).toContain('data?.user?.email')
@@ -473,7 +807,7 @@ describe('emitStudioProject (per-block screens)', () => {
473
807
 
474
808
  it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
475
809
  // Off by default.
476
- let sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
810
+ const sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
477
811
  expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
478
812
  // Toggle on but no SQL entity (memory source) -> nothing.
479
813
  expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
@@ -635,6 +969,38 @@ describe('emitStudioProject (per-block screens)', () => {
635
969
  expect(users).not.toContain('viewer@example.com')
636
970
  })
637
971
 
972
+ it('Supabase Auth: gates the app via SvAuthGate over the shared client, not the builtin session', () => {
973
+ let p = createProject([customers])
974
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
975
+ p = setAuth(p, { enabled: true, provider: 'supabase' })
976
+ const files = emitStudioProject(p)
977
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
978
+ expect(layout).toContain('<SvAuthGate')
979
+ expect(layout).toContain("import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'")
980
+ expect(layout).toContain("import { supabaseClient } from '$lib/connections'")
981
+ // Shared client emitted; NO builtin cookie-session scaffold.
982
+ expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
983
+ expect(files.find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
984
+ expect(files.find((f) => f.path === 'src/lib/server/users.ts')).toBeUndefined()
985
+ expect(files.find((f) => f.path === 'src/routes/login/+page.svelte')).toBeUndefined()
986
+ // No cosmetic fake "Sign out" in the account menu - SvAuthGate's own bar owns it.
987
+ expect(layout).not.toMatch(/menuOpen = false\)}>Sign out/)
988
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
989
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
990
+ }
991
+ })
992
+
993
+ it('Supabase Auth emits the shared client even with no Supabase-bound entity', () => {
994
+ let p = createProject([customers]) // stays in-memory
995
+ p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anon' } }
996
+ p = setAuth(p, { enabled: true, provider: 'supabase' })
997
+ const files = emitStudioProject(p)
998
+ expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
999
+ expect(files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents).toContain('<SvAuthGate')
1000
+ // data.ts does NOT import the client (no Supabase data entity uses it).
1001
+ expect(files.find((f) => f.path === 'src/lib/data.ts')!.contents).not.toContain("from './connections'")
1002
+ })
1003
+
638
1004
  it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
639
1005
  let p = createProject([customers, orders])
640
1006
  p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
@@ -665,6 +1031,191 @@ describe('emitStudioProject (per-block screens)', () => {
665
1031
  expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
666
1032
  })
667
1033
 
1034
+ it('every registered toolbox component generates code that compiles', () => {
1035
+ // The toolbox is registry-driven, so a new registration reaches codegen with
1036
+ // no per-component work - which is exactly why it needs a blanket check that
1037
+ // each one actually emits a valid import + usage.
1038
+ for (const spec of UI_COMPONENT_REGISTRY) {
1039
+ let p = createProject([customers])
1040
+ const sid = p.screens[0]!.id
1041
+ const defaults: Record<string, unknown> = {}
1042
+ for (const pr of spec.props) if (pr.default != null) defaults[pr.key] = pr.default
1043
+ if (spec.hasContent) defaults._content = spec.contentDefault ?? spec.label
1044
+ p = addComponentBlock(p, sid, spec.key, defaults)
1045
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
1046
+ expect(page.contents, `${spec.key} did not import ${spec.importName}`).toContain(spec.importName)
1047
+ expect(() => compile(page.contents, { filename: page.path, generate: 'client' }), `${spec.key} emitted invalid Svelte`).not.toThrow()
1048
+ }
1049
+ })
1050
+
1051
+ describe('multi-tenancy', () => {
1052
+ /** Tenancy needs auth (to know the tenant) + the typed layer (for the column). */
1053
+ const tenantProject = () => {
1054
+ let p = createProject([customers, orders])
1055
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1056
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1057
+ p = setDataLayer(p, true)
1058
+ p = setAuth(p, { enabled: true })
1059
+ return setTenancy(p, { enabled: true })
1060
+ }
1061
+
1062
+ it('scopes every SQL route server-side', () => {
1063
+ const files = emitStudioProject(tenantProject())
1064
+ for (const entity of ['customers', 'orders']) {
1065
+ const route = files.find((f) => f.path === `src/routes/api/${entity}/+server.ts`)!.contents
1066
+ expect(route, entity).toContain("import { requireTenant } from '$lib/server/tenant'")
1067
+ expect(route, entity).toContain("scope: ({ event }) => ({ field: \"tenantId\", value: requireTenant(event) })")
1068
+ }
1069
+ })
1070
+
1071
+ it('resolves the tenant from the session and fails closed', () => {
1072
+ const files = emitStudioProject(tenantProject())
1073
+ const tenant = files.find((f) => f.path === 'src/lib/server/tenant.ts')!.contents
1074
+ // requireTenant THROWS rather than returning null - the transport turns that
1075
+ // into a 403. Returning null would run the query unscoped.
1076
+ expect(tenant).toContain('export function requireTenant')
1077
+ expect(tenant).toContain('throw new Error')
1078
+ // The tenant comes off the session in hooks, never from the client.
1079
+ const hooks = files.find((f) => f.path === 'src/hooks.server.ts')!.contents
1080
+ expect(hooks).toContain('event.locals.tenantId')
1081
+ })
1082
+
1083
+ it('adds the scoping column to every scoped table + the user store', () => {
1084
+ const schema = emitStudioProject(tenantProject()).find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
1085
+ // notNull: a row with no tenant belongs to nobody and no scoped read finds it.
1086
+ expect(schema).toContain('"tenantId": text("tenant_id").notNull()')
1087
+ // The user store carries it too - that is where the session's tenant comes from.
1088
+ expect(schema.slice(schema.indexOf('authUsers'))).toContain('"tenantId"')
1089
+ })
1090
+
1091
+ it('leaves shared entities global (no column, no scope)', () => {
1092
+ let p = tenantProject()
1093
+ p = setTenancy(p, { enabled: true, sharedEntities: ['orders'] })
1094
+ const files = emitStudioProject(p)
1095
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('scope:')
1096
+ expect(files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents).not.toContain('scope:')
1097
+ })
1098
+
1099
+ it('honours a custom scoping column', () => {
1100
+ let p = tenantProject()
1101
+ p = setTenancy(p, { enabled: true, field: 'orgId' })
1102
+ const files = emitStudioProject(p)
1103
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('field: "orgId"')
1104
+ expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('"orgId": text("org_id").notNull()')
1105
+ })
1106
+
1107
+ it('degrades to OFF without auth or the typed data layer, rather than half-enforcing', () => {
1108
+ // No auth: nothing can tell us who the tenant is.
1109
+ let noAuth = createProject([customers])
1110
+ noAuth = setEntityDataSource(noAuth, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1111
+ noAuth = setDataLayer(noAuth, true)
1112
+ noAuth = setTenancy(noAuth, { enabled: true })
1113
+ expect(emitStudioProject(noAuth).find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1114
+
1115
+ // No typed layer: the scoping column would not exist in any schema.
1116
+ let noLayer = createProject([customers])
1117
+ noLayer = setEntityDataSource(noLayer, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1118
+ noLayer = setAuth(noLayer, { enabled: true })
1119
+ noLayer = setTenancy(noLayer, { enabled: true })
1120
+ const files = emitStudioProject(noLayer)
1121
+ expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1122
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).not.toContain('scope:')
1123
+ })
1124
+
1125
+ it('emits nothing tenancy-related when it is off', () => {
1126
+ const files = emitStudioProject(createProject([customers]))
1127
+ expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
1128
+ })
1129
+ })
1130
+
1131
+ it('Scheduled jobs emit a guarded /api/cron + handler registry', () => {
1132
+ let p = createProject([customers, orders])
1133
+ p = setJob(p, 'nightly-digest', { name: 'Nightly digest', cron: '0 6 * * *', kind: 'code', code: "console.log('tick')" })
1134
+ const files = emitStudioProject(p)
1135
+
1136
+ const route = files.find((f) => f.path === 'src/routes/api/cron/+server.ts')!.contents
1137
+ // Secret-guarded, and refuses to run when no secret is configured - an
1138
+ // unguarded "do work" URL is the failure mode worth preventing.
1139
+ expect(route).toContain('env.CRON_SECRET')
1140
+ expect(route).toContain('if (!secret) return false')
1141
+ expect(route).toContain("throw error(401")
1142
+ // One failing job must not abort the rest of the run.
1143
+ expect(route).toContain('try { await fn()')
1144
+
1145
+ const jobs = files.find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1146
+ expect(jobs).toContain('"nightly-digest"')
1147
+ expect(jobs).toContain("console.log('tick')")
1148
+ expect(jobs).toContain('export const enabledJobs: string[] = ["nightly-digest"]')
1149
+ })
1150
+
1151
+ it('a disabled job keeps its handler but drops out of the scheduled run', () => {
1152
+ let p = createProject([customers])
1153
+ p = setJob(p, 'a', { name: 'A', cron: '0 1 * * *', kind: 'code', code: 'void 0' })
1154
+ p = setJob(p, 'b', { name: 'B', cron: '0 2 * * *', kind: 'code', code: 'void 0', enabled: false })
1155
+ const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1156
+ expect(jobs).toContain('"a"')
1157
+ expect(jobs).toContain('"b"') // still callable via ?job=b
1158
+ expect(jobs).toContain('export const enabledJobs: string[] = ["a"]')
1159
+ })
1160
+
1161
+ it('an email job without the email layer emits a warning slot, not a broken import', () => {
1162
+ let p = createProject([customers])
1163
+ p = setJob(p, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'email', entity: 'customers', to: 'ops@example.com' })
1164
+ const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1165
+ expect(jobs).not.toContain("from '$lib/server/email'")
1166
+ expect(jobs).toContain('email is not enabled on this project')
1167
+ })
1168
+
1169
+ it('an email job with the email layer on sends a summary of its entity', () => {
1170
+ let p = createProject([customers, orders])
1171
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1172
+ p = setDataLayer(p, true)
1173
+ p = setAuth(p, { enabled: true, email: true })
1174
+ p = setJob(p, 'digest', { name: 'Daily orders', cron: '0 6 * * *', kind: 'email', entity: 'orders', to: 'ops@example.com', subject: 'Orders today' })
1175
+ const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
1176
+ expect(jobs).toContain("import { sendEmail } from '$lib/server/email'")
1177
+ expect(jobs).toContain("sendEmail('ops@example.com', 'Orders today'")
1178
+ })
1179
+
1180
+ it('no jobs emits no cron route at all', () => {
1181
+ const files = emitStudioProject(createProject([customers]))
1182
+ expect(files.find((f) => f.path === 'src/routes/api/cron/+server.ts')).toBeUndefined()
1183
+ expect(files.find((f) => f.path === 'src/lib/server/jobs.ts')).toBeUndefined()
1184
+ })
1185
+
1186
+ it('Audit persists to an audit_log table when the typed data layer is on', () => {
1187
+ let p = createProject([customers, orders])
1188
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1189
+ p = { ...p, audit: true, dataLayer: 'drizzle' }
1190
+ const files = emitStudioProject(p)
1191
+
1192
+ // The trail gets a real table in the same schema, so one migration covers it.
1193
+ const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
1194
+ expect(schema).toContain('export const auditLog =')
1195
+ expect(schema).toContain('"audit_log"')
1196
+ // before / after snapshots - the in-memory store only kept field names.
1197
+ expect(schema).toContain('"before"')
1198
+ expect(schema).toContain('"after"')
1199
+
1200
+ const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
1201
+ expect(audit).toContain("from '$lib/server/db'")
1202
+ expect(audit).toContain('db.insert(auditLog)')
1203
+ // Paged in the database, and read-only (append-only through recordAudit).
1204
+ expect(audit).toContain('async getRows(')
1205
+ expect(audit).toContain('.limit(limit).offset(start)')
1206
+ expect(audit).not.toContain('createInMemoryDataSource')
1207
+ })
1208
+
1209
+ it('Audit stays in-memory (and says so) without the typed data layer', () => {
1210
+ let p = createProject([customers, orders])
1211
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
1212
+ p = { ...p, audit: true }
1213
+ const files = emitStudioProject(p)
1214
+ const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
1215
+ expect(audit).toContain('createInMemoryDataSource')
1216
+ expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
1217
+ })
1218
+
668
1219
  it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
669
1220
  let p = createProject([customers, orders])
670
1221
  const cid = p.screens.find((s) => s.entity === 'customers')!.id
@@ -887,7 +1438,7 @@ describe('emitStudioProject (per-block screens)', () => {
887
1438
  expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
888
1439
 
889
1440
  const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
890
- expect(ord).toContain("import { page } from '$app/stores'") // drill TARGET reads URL params
1441
+ expect(ord).toContain("import { page } from '$app/state'") // drill TARGET reads URL params
891
1442
  expect(ord).toContain('sp.get(_f)')
892
1443
 
893
1444
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
@@ -905,7 +1456,15 @@ describe('emitStudioProject (per-block screens)', () => {
905
1456
  expect(cat).toContain('export function localizeCols')
906
1457
  expect(cat).toContain('"col.customers.name"') // seeded from the schema
907
1458
  expect(cat).toContain('"nav.customers"')
908
- expect(cat).toContain('"es": {}') // other locale left for translators
1459
+ // EVERY locale is seeded with the same keys, not just the default - an empty
1460
+ // `{}` made the translator hunt for key names in the codegen.
1461
+ expect(cat).not.toContain('"es": {}')
1462
+ expect(cat).toMatch(/"es": \{[\s\S]*"col\.customers\.name"/)
1463
+ expect(cat).toContain('// TODO: translate')
1464
+ // Both locales carry the full key set.
1465
+ const keyCount = (block: string) => (block.match(/"col\.customers\./g) ?? []).length
1466
+ const [, enBlock = '', esBlock = ''] = cat.split(/"(?:en|es)": /)
1467
+ expect(keyCount(enBlock)).toBe(keyCount(esBlock))
909
1468
 
910
1469
  const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
911
1470
  expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
@@ -1034,6 +1593,49 @@ describe('Custom actions', () => {
1034
1593
  })
1035
1594
  })
1036
1595
 
1596
+ describe('form/dialog layout depth', () => {
1597
+ const gridScreen = (extra: Partial<GridConfig>) => {
1598
+ let p = createProject([customers])
1599
+ const sid = p.screens[0]!.id
1600
+ const gid = p.screens[0]!.blocks[0]!.id
1601
+ p = updateBlock(p, sid, gid, { config: { editing: 'form', ...extra } as Partial<GridConfig> })
1602
+ return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1603
+ }
1604
+
1605
+ const panelLine = (page: string) => page.split('\n').find((l) => l.includes('<SvGridEditPanel schema='))!
1606
+
1607
+ it('emits nothing extra for a default form', () => {
1608
+ const line = panelLine(gridScreen({}))
1609
+ expect(line).toMatch(/<SvGridEditPanel schema=\{customersSchema\} row=\{editing\}/)
1610
+ expect(line).not.toMatch(/columns=\{/)
1611
+ expect(line).not.toMatch(/sections=\{/)
1612
+ })
1613
+
1614
+ it('passes columns / field selection+order / sections / title / size to the panel', () => {
1615
+ const page = gridScreen({
1616
+ formColumns: 3,
1617
+ formFields: ['name', 'email'],
1618
+ formSections: [{ title: 'Contact', fields: ['name', 'email'] }],
1619
+ formTitle: 'Edit customer',
1620
+ formSize: 'lg',
1621
+ })
1622
+ const line = panelLine(page)
1623
+ expect(line).toMatch(/columns=\{3\}/)
1624
+ expect(line).toMatch(/formFields=\{\["name","email"\]\}/)
1625
+ expect(line).toMatch(/sections=\{\[\{"title":"Contact","fields":\["name","email"\]\}\]\}/)
1626
+ expect(line).toMatch(/title=\{'Edit customer'\}/)
1627
+ expect(line).toMatch(/formSize='lg'/)
1628
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
1629
+ })
1630
+
1631
+ it('omits default-valued knobs (columns 2, size md)', () => {
1632
+ const line = panelLine(gridScreen({ formColumns: 2, formSize: 'md', formFields: ['name'] }))
1633
+ expect(line).not.toMatch(/columns=\{/)
1634
+ expect(line).not.toMatch(/formSize=/)
1635
+ expect(line).toMatch(/formFields=\{\["name"\]\}/)
1636
+ })
1637
+ })
1638
+
1037
1639
  describe('Component blocks', () => {
1038
1640
  it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
1039
1641
  let p = createProject([customers])
@@ -1044,7 +1646,10 @@ describe('Component blocks', () => {
1044
1646
  const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1045
1647
  const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
1046
1648
  expect(importLine).toContain('SvButton') // merged into the screen's one @svgrid/grid import
1047
- expect(page).toContain("<SvButton variant={'primary'} size={'md'} block>{'Click me'}</SvButton>")
1649
+ // Full extracted surface: curated attrs first, then the rest of the component's
1650
+ // real props (e.g. type={'button'}); content children last.
1651
+ expect(page).toContain("<SvButton variant={'primary'} size={'md'} block")
1652
+ expect(page).toContain(">{'Click me'}</SvButton>")
1048
1653
 
1049
1654
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1050
1655
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
@@ -1060,8 +1665,10 @@ describe('Component blocks', () => {
1060
1665
 
1061
1666
  const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1062
1667
  expect(page).toContain("import { SvBadge, SvButton } from '@svgrid/grid'") // sorted, deduped
1063
- expect(page).toContain("<SvButton variant={'secondary'} size={'md'}>{'Click me'}</SvButton>")
1064
- expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill>{'Badge'}</SvBadge>")
1668
+ expect(page).toContain("<SvButton variant={'secondary'} size={'md'}")
1669
+ expect(page).toContain(">{'Click me'}</SvButton>")
1670
+ expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill")
1671
+ expect(page).toContain(">{'Badge'}</SvBadge>")
1065
1672
  expect(page).not.toContain('Add your own content here')
1066
1673
 
1067
1674
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
@@ -1180,6 +1787,41 @@ describe('data-source codegen', () => {
1180
1787
  expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
1181
1788
  })
1182
1789
 
1790
+ it('REST adapter (dummyjson): emits real server paging via the adapter + imports it', () => {
1791
+ let p = createProject([customers])
1792
+ p = setEntityDataSource(p, 'customers', {
1793
+ kind: 'rest', baseUrl: 'https://dummyjson.com', path: 'users', method: 'GET', params: [],
1794
+ adapter: { kind: 'dummyjson', rowsKey: 'users' },
1795
+ })
1796
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1797
+ expect(dataTs).toContain('dummyJsonAdapter') // imported from @svgrid/enterprise
1798
+ expect(dataTs).toMatch(/\.\.\.dummyJsonAdapter<\w+>\('users'\)/)
1799
+ })
1800
+
1801
+ it('REST offsetLimit adapter emits only the configured params', () => {
1802
+ let p = createProject([customers])
1803
+ p = setEntityDataSource(p, 'customers', {
1804
+ kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
1805
+ adapter: { kind: 'offsetLimit', offsetParam: '_start', limitParam: '_limit', sortByParam: 'sort' },
1806
+ })
1807
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1808
+ expect(dataTs).toContain("offsetParam: '_start'")
1809
+ expect(dataTs).toContain("limitParam: '_limit'")
1810
+ expect(dataTs).toContain("sortByParam: 'sort'")
1811
+ expect(dataTs).not.toContain('orderParam') // unset fields are omitted
1812
+ })
1813
+
1814
+ it('a REST adapter supersedes the manual rowsPath/totalPath parse', () => {
1815
+ let p = createProject([customers])
1816
+ p = setEntityDataSource(p, 'customers', {
1817
+ kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
1818
+ adapter: { kind: 'jsonServer' }, rowsPath: 'data', totalPath: 'total',
1819
+ })
1820
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1821
+ expect(dataTs).toContain('jsonServerAdapter')
1822
+ expect(dataTs).not.toContain('parse: (body: any)') // adapter provides parse instead
1823
+ })
1824
+
1183
1825
  it('substitutes static path params into the URL', () => {
1184
1826
  let p = createProject([customers])
1185
1827
  p = setEntityDataSource(p, 'customers', {
@@ -1245,20 +1887,30 @@ describe('data-source codegen', () => {
1245
1887
  expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
1246
1888
  })
1247
1889
 
1248
- it('emits a real Supabase client from a project URL + anon key', () => {
1890
+ it('emits a Supabase client that reads PUBLIC_SUPABASE_* env vars (never inlines the key)', () => {
1249
1891
  let p = createProject([customers])
1250
1892
  p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
1251
1893
  const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
1252
1894
  expect(conn).toContain("import { createClient } from '@supabase/supabase-js'")
1253
- expect(conn).toContain("createClient('https://x.supabase.co', 'anon123')")
1254
- })
1255
-
1256
- it('falls back to a Supabase null stub without url/key', () => {
1895
+ expect(conn).toContain("import { env } from '$env/dynamic/public'")
1896
+ expect(conn).toContain('createClient(')
1897
+ expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
1898
+ expect(conn).toContain('env.PUBLIC_SUPABASE_ANON_KEY')
1899
+ // The anon key is NEVER baked into source (it goes to .env); the designer values
1900
+ // appear only as a paste-into-.env comment.
1901
+ expect(conn).not.toContain("createClient('https://x.supabase.co'")
1902
+ // .env.example lists the public Supabase keys.
1903
+ const envEx = emitStudioAppBundle(p).find((f) => f.path === '.env.example')!.contents
1904
+ expect(envEx).toContain('PUBLIC_SUPABASE_URL=')
1905
+ expect(envEx).toContain('PUBLIC_SUPABASE_ANON_KEY=')
1906
+ })
1907
+
1908
+ it('still emits an env-var Supabase client without designer url/key', () => {
1257
1909
  let p = createProject([customers])
1258
1910
  p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1259
1911
  const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
1260
- expect(conn).toContain('supabaseClient = null as unknown as SupabaseClientLike')
1261
- expect(conn).not.toMatch(/^import \{ createClient \}/m) // no real client import without creds
1912
+ expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
1913
+ expect(conn).not.toContain('null as unknown as SupabaseClientLike') // no stub - reads env
1262
1914
  })
1263
1915
 
1264
1916
  it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
@@ -1269,6 +1921,50 @@ describe('data-source codegen', () => {
1269
1921
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1270
1922
  }
1271
1923
  })
1924
+
1925
+ it('Supabase realtime: subscribes the grid page to live changes and refreshes on change', () => {
1926
+ let p = createProject([customers])
1927
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', realtime: true })
1928
+ const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
1929
+ expect(page).toContain('createSupabaseRealtime')
1930
+ expect(page).toContain("import { supabaseClient } from '$lib/connections'")
1931
+ expect(page).toContain("table: 'customers'")
1932
+ expect(page).toContain('onChange: () => controller.refresh()')
1933
+ // Cleanup unsubscribes the channel alongside disposing the controller.
1934
+ expect(page).toContain('__rt.unsubscribe()')
1935
+ expect(page).toContain('controller.dispose()')
1936
+ // Compiles.
1937
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
1938
+ })
1939
+
1940
+ it('Supabase without realtime does NOT emit a subscription', () => {
1941
+ let p = createProject([customers])
1942
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1943
+ const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
1944
+ expect(page).not.toContain('createSupabaseRealtime')
1945
+ })
1946
+
1947
+ it('project-level Supabase connection feeds the .env paste hint (entity carries only the table)', () => {
1948
+ let p = createProject([customers])
1949
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1950
+ p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anonKEY' } }
1951
+ const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
1952
+ expect(conn).toContain('PUBLIC_SUPABASE_URL=https://proj.supabase.co')
1953
+ expect(conn).toContain('PUBLIC_SUPABASE_ANON_KEY=anonKEY')
1954
+ // The client still reads env (project-level values only seed the paste comment).
1955
+ expect(conn).not.toContain("createClient('https://proj.supabase.co'")
1956
+ })
1957
+
1958
+ it('SQL schema selector qualifies the table in the generated route', () => {
1959
+ let p = createProject([customers])
1960
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres', schema: 'analytics' })
1961
+ const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!.contents
1962
+ expect(route).toContain('dbSchema: "analytics"')
1963
+ // No schema set -> no dbSchema option (unqualified table).
1964
+ let q = createProject([customers])
1965
+ q = setEntityDataSource(q, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1966
+ expect(byPathOf(emitStudioProject(q), 'src/routes/api/customers/+server.ts')!.contents).not.toContain('dbSchema:')
1967
+ })
1272
1968
  })
1273
1969
 
1274
1970
  describe('grid editing (a Grid property) -> codegen', () => {
@@ -1499,6 +2195,50 @@ describe('grid editing (a Grid property) -> codegen', () => {
1499
2195
  expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
1500
2196
  })
1501
2197
 
2198
+ it('xlsx / pdf / print buttons go through @svgrid/enterprise and compile', () => {
2199
+ let p = createProject([customers])
2200
+ const sid = p.screens[0]!.id
2201
+ const gid = p.screens[0]!.blocks[0]!.id
2202
+ p = updateBlock(p, sid, gid, { config: { export: { xlsx: true, pdf: true, print: true } } as Partial<import('./project').BlockConfig> })
2203
+ const page = pageFor(p)
2204
+ // Imported from the enterprise pack, not the free grid api.
2205
+ expect(page).toMatch(/import \{[^}]*exportGrid[^}]*\} from '@svgrid\/enterprise'/)
2206
+ expect(page).toMatch(/import \{[^}]*printGrid[^}]*\} from '@svgrid\/enterprise'/)
2207
+ expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'xlsx', 'customers')")
2208
+ expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'pdf', 'customers')")
2209
+ expect(page).toContain('stPrint(gridApi_' + gid.replace(/-/g, '_') + ", 'customers')")
2210
+ // Helpers emitted exactly once each, beside the buttons that call them.
2211
+ expect(page.match(/async function stExport\(/g)?.length).toBe(1)
2212
+ expect(page.match(/async function stPrint\(/g)?.length).toBe(1)
2213
+ for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
2214
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
2215
+ }
2216
+ })
2217
+
2218
+ it('declares the optional peer deps the enterprise exporters need', () => {
2219
+ const base = createProject([customers])
2220
+ const sid = base.screens[0]!.id
2221
+ const gid = base.screens[0]!.blocks[0]!.id
2222
+ const pkgOf = (proj: typeof base) => {
2223
+ const f = emitStudioAppBundle(proj).find((f) => f.path === 'package.json')!
2224
+ return JSON.parse(f.contents).dependencies as Record<string, string>
2225
+ }
2226
+ // jszip (xlsx) and pdfmake (pdf) are imported lazily INSIDE @svgrid/enterprise,
2227
+ // so they never appear in the generated source - they must come from the config.
2228
+ const xlsxDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { xlsx: true } } as Partial<import('./project').BlockConfig> }))
2229
+ expect(xlsxDeps).toHaveProperty('jszip')
2230
+ expect(xlsxDeps).not.toHaveProperty('pdfmake')
2231
+
2232
+ const pdfDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { pdf: true } } as Partial<import('./project').BlockConfig> }))
2233
+ expect(pdfDeps).toHaveProperty('pdfmake')
2234
+ expect(pdfDeps).not.toHaveProperty('jszip')
2235
+
2236
+ // A csv-only app drags in neither.
2237
+ const csvDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { csv: true } } as Partial<import('./project').BlockConfig> }))
2238
+ expect(csvDeps).not.toHaveProperty('jszip')
2239
+ expect(csvDeps).not.toHaveProperty('pdfmake')
2240
+ })
2241
+
1502
2242
  it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
1503
2243
  let p = createProject([customers])
1504
2244
  const sid = p.screens[0]!.id
@@ -1710,7 +2450,7 @@ describe('dock layout (screen.layout = dock)', () => {
1710
2450
  const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
1711
2451
  expect(page).toContain('async function save({ mode, id, values }')
1712
2452
  expect(page).toContain('const row = values')
1713
- expect(page).toContain('const ctx = { grid: gridApi!,')
2453
+ expect(page).toContain('const ctx = { grid: gridCtx,')
1714
2454
  expect(page).toContain("ctx.goto('/customers')")
1715
2455
  for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
1716
2456
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
@@ -1855,6 +2595,35 @@ describe('emitStudioAppBundle (full runnable app)', () => {
1855
2595
  expect(pkg.devDependencies['vite']).toBeTruthy()
1856
2596
  })
1857
2597
 
2598
+ it('schedules jobs with the platform: vercel.json crons on Vercel, a workflow elsewhere', () => {
2599
+ let base = createProject([customers])
2600
+ base = setJob(base, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'code', code: 'void 0' })
2601
+
2602
+ const vercel = emitStudioAppBundle(setDeployTarget(base, 'vercel'))
2603
+ const vj = vercel.find((f) => f.path === 'vercel.json')!
2604
+ expect(JSON.parse(vj.contents).crons).toEqual([{ path: '/api/cron?job=digest', schedule: '0 6 * * *' }])
2605
+ // Vercel Cron calls the route itself - no Actions workflow needed.
2606
+ expect(vercel.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
2607
+
2608
+ const node = emitStudioAppBundle(setDeployTarget(base, 'node'))
2609
+ const wf = node.find((f) => f.path === '.github/workflows/cron.yml')!.contents
2610
+ expect(wf).toContain("- cron: '0 6 * * *'")
2611
+ // The endpoint lives in the CRON_URL secret; the workflow appends the job.
2612
+ expect(wf).toContain('"$CRON_URL?job=digest"')
2613
+ // Inert until the secrets exist, so CI is green before it is configured.
2614
+ expect(wf).toContain("secrets.CRON_URL != ''")
2615
+ expect(node.find((f) => f.path === 'vercel.json')).toBeUndefined()
2616
+
2617
+ // The secret the route demands is documented in .env.example.
2618
+ expect(node.find((f) => f.path === '.env.example')!.contents).toContain('CRON_SECRET=')
2619
+ })
2620
+
2621
+ it('emits no schedule config when there are no jobs', () => {
2622
+ const files = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
2623
+ expect(files.find((f) => f.path === 'vercel.json')).toBeUndefined()
2624
+ expect(files.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
2625
+ })
2626
+
1858
2627
  it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
1859
2628
  const bundle = emitStudioAppBundle(createProject([customers]))
1860
2629
  const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
@@ -2043,7 +2812,7 @@ describe('code companion (design + your own code)', () => {
2043
2812
  expect(page.contents).toContain("import * as handlers from './handlers'")
2044
2813
  // onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
2045
2814
  // the settable data battery, since this freestanding page owns its rows).
2046
- expect(page.contents).toContain('const ctx = { grid: gridApi!, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
2815
+ expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
2047
2816
  expect(page.contents).toContain('as PageContext')
2048
2817
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2049
2818
  expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
@@ -2074,10 +2843,12 @@ describe('code companion (design + your own code)', () => {
2074
2843
  const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
2075
2844
  // Named handle (button1) created + wired into the component markup.
2076
2845
  expect(page.contents).toContain('const button1 = handle(')
2077
- expect(page.contents).toContain('<SvButton {...button1.props}>{button1.text}</SvButton>')
2078
- expect(page.contents).toContain("import { handle } from '$lib/handles.svelte'")
2846
+ // Click is wired through the component's own onclick callback prop (exact
2847
+ // semantics), not a bubbling wrapper listener.
2848
+ expect(page.contents).toContain("<SvButton {...button1.props} onclick={(e) => button1.fire('click', e)}>{button1.text}</SvButton>")
2849
+ expect(page.contents).toContain("import { handle, gridHandle } from '$lib/handles.svelte'")
2079
2850
  expect(page.contents).toContain('button1.fire(\'click\', e)')
2080
- expect(page.contents).toContain('const ctx = { grid: gridApi!, button1, data:')
2851
+ expect(page.contents).toContain('const ctx = { grid: gridCtx, button1, data:')
2081
2852
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2082
2853
  // Typed handle: ButtonHandle intersects Handle with the button's real setters.
2083
2854
  expect(ctx.contents).toContain('button1: ButtonHandle')
@@ -2177,7 +2948,7 @@ describe('code companion (design + your own code)', () => {
2177
2948
  expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
2178
2949
  expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
2179
2950
  // Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
2180
- expect(page.contents).toContain('const ctx = { grid: gridApi!, 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) }, goto, params: Object.fromEntries($page.url.searchParams) } as unknown as PageContext')
2951
+ expect(page.contents).toContain('const ctx = { grid: gridCtx, 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) }, goto, params: Object.fromEntries(page.url.searchParams) } as unknown as PageContext')
2181
2952
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2182
2953
  expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
2183
2954
  // The grid api is typed to the entity's row (Customers), not any.
@@ -2203,7 +2974,7 @@ describe('code companion (design + your own code)', () => {
2203
2974
  expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
2204
2975
  expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
2205
2976
  expect(page.contents).toContain('rows={chart1.rows}')
2206
- expect(page.contents).toContain("import { handle, dataHandle } from '$lib/handles.svelte'")
2977
+ expect(page.contents).toContain("import { handle, dataHandle, gridHandle } from '$lib/handles.svelte'")
2207
2978
  // ctx carries the data handles + the entity component handle + batteries.
2208
2979
  expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
2209
2980
  expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')
@@ -2343,7 +3114,12 @@ describe('code companion (design + your own code)', () => {
2343
3114
  expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
2344
3115
  // An unknown ctx member is caught.
2345
3116
  expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
2346
- })
3117
+ // This test runs REAL TypeScript programs over the generated code against
3118
+ // the grid's .d.ts, so it scales with the public type surface and is far
3119
+ // slower than a normal unit test. It passes comfortably in isolation but was
3120
+ // brushing the default 5s budget under full-suite parallel load; every grid
3121
+ // API added since made that worse. Give the compile room.
3122
+ }, 30_000)
2347
3123
 
2348
3124
  it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
2349
3125
  let p = createProject([customers])