@svgrid/enterprise 2.2.1 → 2.3.0

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 (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
@@ -3,7 +3,7 @@ import { compile } from 'svelte/compiler'
3
3
  import type { EntitySchema } from '../schema'
4
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'
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,320 @@ 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('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
58
+ const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
59
+ expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
60
+ const c = server!.contents
61
+ expect(c).toMatch(/export const ssr = true/) // opts back into SSR over the SPA layout's ssr=false
62
+ expect(c).toMatch(/export const load: PageServerLoad/)
63
+ expect(c).toMatch(/export const actions: Actions/)
64
+ expect(c).toMatch(/create:\s*async/)
65
+ expect(c).toMatch(/update:\s*async/)
66
+ expect(c).toMatch(/delete:\s*async/)
67
+ expect(c).toMatch(/await validateAll\(customersSchema, values\)/) // server-side validation
68
+ expect(c).toMatch(/return fail\(422/)
69
+ expect(c).toMatch(/planFromSearchParams\(url/)
70
+ expect(c).toMatch(/await customersSource\.getRows\(plan\)/)
71
+ })
72
+
73
+ it('emits an SSR +page.svelte: server rows, URL-driven sort/page, form-action editing', () => {
74
+ const page = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
75
+ expect(page).toMatch(/import type \{ PageProps \} from '\.\/\$types'/)
76
+ expect(page).toMatch(/data=\{data\.rows\}/) // renders SSR'd rows
77
+ expect(page).toMatch(/externalSort/)
78
+ expect(page).toMatch(/initialSorting=\{data\.sort\}/) // header indicator matches the URL sort on hard reload
79
+ expect(page).toMatch(/externalPagination/)
80
+ expect(page).toMatch(/onSortingChange=/)
81
+ expect(page).toMatch(/use:enhance=\{onSubmit\}/) // progressive enhancement
82
+ expect(page).toMatch(/action=\{isCreate \? '\?\/create' : '\?\/update'\}/)
83
+ // It must NOT fall back to the client data-source controller.
84
+ expect(page).not.toMatch(/createServerDataSource/)
85
+ // Valid Svelte 5.
86
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
87
+ })
88
+
89
+ it('SSR grid honors filterable: URL-driven filter (q + f_<col>) via applyFilters; off when not filterable', () => {
90
+ const p0 = createProject([customers])
91
+ const withFilter: StudioProject = {
92
+ ...p0,
93
+ screens: p0.screens.map((s) => ({
94
+ ...s,
95
+ renderMode: 'ssr' as const,
96
+ blocks: s.blocks.map((b) => (b.config.kind === 'grid' ? { ...b, config: { ...b.config, filterable: true } } : b)),
97
+ })),
98
+ }
99
+ const page = emitStudioProject(withFilter).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
100
+ expect(page).toMatch(/externalFilter/)
101
+ expect(page).toMatch(/onFiltersChange=\{applyFilters\}/)
102
+ expect(page).toMatch(/function applyFilters/)
103
+ expect(page).toMatch(/sp\.set\('f_' \+ c\.id, c\.value\)/) // per-column f_ params
104
+ // The default grid (filterable: false) omits the whole filter surface.
105
+ const off = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
106
+ expect(off).not.toMatch(/externalFilter/)
107
+ expect(off).not.toMatch(/function applyFilters/)
108
+ })
109
+
110
+ it('emits the shared server query helper once', () => {
111
+ const files = emitStudioProject(ssrProject())
112
+ const helpers = files.filter((f) => f.path === 'src/lib/server/query.ts')
113
+ expect(helpers).toHaveLength(1)
114
+ expect(helpers[0]!.contents).toMatch(/export function planFromSearchParams/)
115
+ })
116
+
117
+ it('sql SSR screen: load/actions proxy to the connected /api route via createKitDataSource', () => {
118
+ const p0 = createProject([customers])
119
+ const p: StudioProject = {
120
+ ...p0,
121
+ dataSource: 'sql',
122
+ dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' } },
123
+ screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
124
+ }
125
+ const files = emitStudioProject(p)
126
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
127
+ expect(server).toMatch(/import \{ validateAll, createKitDataSource \} from '@svgrid\/enterprise'/)
128
+ expect(server).toMatch(/createKitDataSource<Customers>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
129
+ expect(server).toMatch(/load: PageServerLoad = async \(\{ url, fetch \}\)/) // load gets event.fetch
130
+ expect(server).not.toMatch(/from '\$lib\/data'/) // no in-process source import for sql
131
+ // The connected /api route is still emitted - enforcement (validate/RBAC/triggers/audit) lives there.
132
+ expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeTruthy()
133
+ // Page still compiles.
134
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
135
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
136
+ })
137
+
138
+ it('read-only SSR screens (dashboard/kpi): load-only server file + page renders from data.*', () => {
139
+ let p = createProject([customers])
140
+ const sid = p.screens[0]!.id
141
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
142
+ p = addBlock(p, sid, 'kpi')
143
+ p = addBlock(p, sid, 'chart')
144
+ const files = emitStudioProject(p)
145
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
146
+ expect(server).toMatch(/export const ssr = true/)
147
+ expect(server).toMatch(/export const load: PageServerLoad/)
148
+ expect(server).toMatch(/const rows = \(await customersSource\.getRows\(PAGE\)\)\.rows/)
149
+ expect(server).not.toMatch(/export const actions/) // read-only
150
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
151
+ expect(page).toMatch(/let \{ data \}: PageProps = \$props\(\)/)
152
+ expect(page).toMatch(/const allRows = \$derived\(data\.rows as Customers\[\]\)/)
153
+ expect(page).not.toMatch(/loadAll\(\)/)
154
+ expect(page).not.toMatch(/from '\$lib\/data'/) // all data comes from the server load
155
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
156
+ })
157
+
158
+ it('read-only SSR + master-detail: child collections load server-side too (sql via /api)', () => {
159
+ const p0 = createProject([customers, orders])
160
+ let p: StudioProject = {
161
+ ...p0,
162
+ dataSource: 'sql',
163
+ dataSources: {
164
+ customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
165
+ orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
166
+ },
167
+ }
168
+ const sid = p.screens[0]!.id
169
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
170
+ p = addBlock(p, sid, 'master-detail')
171
+ const mdId = p.screens[0]!.blocks.at(-1)!.id
172
+ p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
173
+ const files = emitStudioProject(p)
174
+ const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
175
+ expect(server).toMatch(/createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
176
+ expect(server).toMatch(/const md_orders_rows = \(await createKitDataSource.*'\/api\/orders'/)
177
+ expect(server).toMatch(/return \{ rows, md_orders_rows \}/)
178
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
179
+ expect(page).toMatch(/const md_orders_rows = \$derived\(data\.md_orders_rows as Orders\[\]\)/)
180
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
181
+ })
182
+
183
+ it('a board screen stays SPA even with renderMode ssr (client-only interaction)', () => {
184
+ let p = createProject([customers])
185
+ const sid = p.screens[0]!.id
186
+ p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
187
+ p = addBlock(p, sid, 'board')
188
+ const files = emitStudioProject(p)
189
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
190
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents).toMatch(/loadAll\(\)/)
191
+ })
192
+
193
+ it('sql SSR + relation field: prefetches options via the related /api route', () => {
194
+ const p0 = createProject([customers, orders])
195
+ const p: StudioProject = {
196
+ ...p0,
197
+ dataSource: 'sql',
198
+ dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' }, orders: { kind: 'sql', dialect: 'postgres', table: 'orders' } },
199
+ screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)),
200
+ }
201
+ const server = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
202
+ expect(server).toMatch(/const customer_idOptions = \(await createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)\.getRows\(/)
203
+ expect(server).toMatch(/, customer_idOptions \}/)
204
+ expect(server).not.toMatch(/from '\$lib\/data'/) // sql uses the /api route, not the in-process source
205
+ const page = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
206
+ expect(page).toMatch(/<select name='customer_id'/)
207
+ expect(page).toMatch(/#each data\.customer_idOptions as o/)
208
+ })
209
+
210
+ it('memory SSR + RBAC: load/actions enforce authz inline (sql inherits from /api)', () => {
211
+ const p0 = createProject([customers])
212
+ const p: StudioProject = {
213
+ ...p0,
214
+ access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: p0.screens.map((s) => s.id), actions: [] }] },
215
+ screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
216
+ }
217
+ const server = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
218
+ expect(server).toMatch(/import \{ fail, error \} from '@sveltejs\/kit'/)
219
+ expect(server).toMatch(/import \{ authorizeAction, getServerRole \} from '\$lib\/access'/)
220
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'read', SCREEN_IDS\)/)
221
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'create', SCREEN_IDS\)/)
222
+ expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'delete', SCREEN_IDS\)/)
223
+ })
224
+
225
+ it('memory SSR + relation field: prefetches options in load and renders a <select>', () => {
226
+ const p0 = createProject([customers, orders])
227
+ const p: StudioProject = { ...p0, screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)) }
228
+ const files = emitStudioProject(p)
229
+ const server = files.find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
230
+ expect(server).toMatch(/import \{ ordersSource, customersSource, nextId \} from '\$lib\/data'/)
231
+ expect(server).toMatch(/const customer_idOptions = \(await customersSource\.getRows\(/)
232
+ expect(server).toMatch(/label: String\(r\['name'\]/) // uses the relation's labelField
233
+ expect(server).toMatch(/, customer_idOptions \}/) // options returned from load
234
+ const page = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
235
+ expect(page).toMatch(/<select name='customer_id'/)
236
+ expect(page).toMatch(/#each data\.customer_idOptions as o/)
237
+ expect(() => compile(page, { generate: 'client' })).not.toThrow()
238
+ })
239
+
240
+ it('leaves spa screens on the client-controller path', () => {
241
+ const p = createProject([customers]) // default renderMode is spa
242
+ const files = emitStudioProject(p)
243
+ expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
244
+ expect(files.find((f) => f.path === 'src/lib/server/query.ts')).toBeUndefined()
245
+ const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
246
+ expect(page).toMatch(/createServerDataSource/)
247
+ })
248
+ })
249
+
250
+ describe('emitStudioFragment (drop into an existing app)', () => {
251
+ it('emits app content minus the shell, plus app.css + FRAGMENT.md', () => {
252
+ const files = emitStudioFragment(createProject([customers]))
253
+ const paths = files.map((f) => f.path)
254
+ // No whole-app scaffolding.
255
+ expect(paths).not.toContain('package.json')
256
+ expect(paths).not.toContain('svelte.config.js')
257
+ expect(paths).not.toContain('src/routes/+layout.svelte') // the nav shell
258
+ expect(paths).not.toContain('src/routes/+page.svelte') // the home redirect
259
+ // The screens + lib + the lifted styles + the readme ARE present.
260
+ expect(paths).toContain('src/routes/customers/+page.svelte')
261
+ expect(paths).toContain('src/lib/schemas.ts')
262
+ expect(paths).toContain('src/lib/data.ts')
263
+ expect(paths).toContain('src/app.css')
264
+ expect(paths).toContain('FRAGMENT.md')
265
+ })
266
+
267
+ it('FRAGMENT.md reports the runtime deps + host requirements', () => {
268
+ let p = createProject([customers])
269
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
270
+ const md = emitStudioFragment(p).find((f) => f.path === 'FRAGMENT.md')!.contents
271
+ expect(md).toMatch(/npm install .*\bpg\b/) // the sql driver the fragment imports
272
+ expect(md).toMatch(/DATABASE_URL/) // env the host must set
273
+ expect(md).toMatch(/Import `src\/app\.css`/)
274
+ expect(md).toMatch(/nav shell.*NOT here/s)
275
+ })
276
+ })
277
+
278
+ describe('db/schema.sql + db/seed.sql + drizzle db:seed (real-DB last mile)', () => {
279
+ const sqlProject = (dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'turso'): StudioProject => {
280
+ let p = createProject([customers, orders])
281
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
282
+ p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect })
283
+ return p
284
+ }
285
+
286
+ it('emits db/schema.sql + db/seed.sql for SQL entities without the Drizzle layer', () => {
287
+ const files = emitStudioProject(sqlProject('postgres'))
288
+ const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
289
+ expect(ddl).toMatch(/CREATE TABLE IF NOT EXISTS "customers" \(/)
290
+ expect(ddl).toMatch(/"id" text PRIMARY KEY/)
291
+ expect(ddl).toMatch(/"mrr" double precision/)
292
+ // Relation display fields are runtime labels, not columns.
293
+ const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
294
+ expect(seedSql).toMatch(/INSERT INTO "customers" \(/)
295
+ expect(seedSql).toMatch(/INSERT INTO "orders" \(/)
296
+ })
297
+
298
+ it('mssql gets a guarded CREATE TABLE (no Drizzle possible) with N-literals', () => {
299
+ const files = emitStudioProject(sqlProject('mssql'))
300
+ const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
301
+ expect(ddl).toMatch(/IF OBJECT_ID\(N'customers', N'U'\) IS NULL/)
302
+ expect(ddl).toMatch(/"mrr" float/)
303
+ const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
304
+ expect(seedSql).toMatch(/N'/)
305
+ // Even WITH the drizzle toggle, mssql still ships plain SQL (dzDialect is null).
306
+ const withLayer = emitStudioProject(setDataLayer(sqlProject('mssql'), true))
307
+ expect(withLayer.some((f) => f.path === 'db/schema.sql')).toBe(true)
308
+ })
309
+
310
+ it('mysql uses backticks; sqlite/turso use sqlite types', () => {
311
+ const my = emitStudioProject(sqlProject('mysql')).find((f) => f.path === 'db/schema.sql')!.contents
312
+ expect(my).toMatch(/CREATE TABLE IF NOT EXISTS `customers` \(/)
313
+ expect(my).toMatch(/`mrr` double/)
314
+ const tu = emitStudioProject(sqlProject('turso')).find((f) => f.path === 'db/schema.sql')!.contents
315
+ expect(tu).toMatch(/"mrr" real/)
316
+ })
317
+
318
+ it('the Drizzle layer replaces plain DDL with migrations + a db:seed script', () => {
319
+ const p = setDataLayer(sqlProject('postgres'), true)
320
+ const files = emitStudioAppBundle(p)
321
+ expect(files.some((f) => f.path === 'db/schema.sql')).toBe(false) // migrations own DDL
322
+ const seedTs = files.find((f) => f.path === 'db/seed.ts')!.contents
323
+ expect(seedTs).toMatch(/studio:db-seed/)
324
+ expect(seedTs).toMatch(/process\.env\.DATABASE_URL/) // own client - no \$env import
325
+ expect(seedTs).not.toMatch(/\$env\/dynamic\/private/)
326
+ expect(seedTs).toMatch(/db\.insert\(schema\.customers\)/)
327
+ expect(seedTs).toMatch(/existing\.length === 0/) // idempotent
328
+ const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
329
+ expect(pkg.scripts['db:seed']).toBe('tsx db/seed.ts')
330
+ expect(pkg.devDependencies.tsx).toBeTruthy()
331
+ const deploy = files.find((f) => f.path === 'DEPLOY.md')!.contents
332
+ expect(deploy).toMatch(/npm run db:seed/)
333
+ })
334
+
335
+ it('DEPLOY.md points at db/schema.sql on the non-Drizzle path', () => {
336
+ const deploy = emitStudioAppBundle(sqlProject('postgres')).find((f) => f.path === 'DEPLOY.md')!.contents
337
+ expect(deploy).toMatch(/db\/schema\.sql/)
338
+ expect(deploy).toMatch(/VITE_SVPRO_KEY/)
339
+ })
340
+ })
341
+
342
+ describe('secure-by-default auth', () => {
343
+ const authApp = () => emitStudioAppBundle(setAuth(createProject([customers]), { enabled: true, protect: true }))
344
+
345
+ it('ships a git-ignored .env with a real random SESSION_SECRET (not the hardcoded fallback)', () => {
346
+ const env = authApp().find((f) => f.path === '.env')
347
+ expect(env, 'an auth app should ship a .env with a real secret').toBeTruthy()
348
+ expect(env!.contents).toMatch(/SESSION_SECRET=[0-9a-f]{64}/)
349
+ expect(env!.contents).not.toMatch(/change-me-to-a-long-random-string/)
350
+ })
351
+
352
+ it('hashes demo users - no plaintext store or plaintext login compare', () => {
353
+ const files = authApp()
354
+ const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
355
+ expect(users).toMatch(/passwordHash: await hashPassword/)
356
+ // The stored user type carries only the hash (the SEED keeps plaintext defaults,
357
+ // which are hashed at module load).
358
+ expect(users).toMatch(/AppUser = \{ email: string; name: string; role: string; passwordHash: string \}/)
359
+ const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
360
+ expect(login).toMatch(/await verifyPassword\(password, user\.passwordHash\)/)
361
+ expect(login).not.toMatch(/user\.password !== password/)
362
+ })
363
+ })
364
+
51
365
  describe('emitStudioProject (per-block screens)', () => {
52
366
  const project = createProject([customers, orders])
53
367
  const files = emitStudioProject(project)
@@ -635,6 +949,38 @@ describe('emitStudioProject (per-block screens)', () => {
635
949
  expect(users).not.toContain('viewer@example.com')
636
950
  })
637
951
 
952
+ it('Supabase Auth: gates the app via SvAuthGate over the shared client, not the builtin session', () => {
953
+ let p = createProject([customers])
954
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
955
+ p = setAuth(p, { enabled: true, provider: 'supabase' })
956
+ const files = emitStudioProject(p)
957
+ const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
958
+ expect(layout).toContain('<SvAuthGate')
959
+ expect(layout).toContain("import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'")
960
+ expect(layout).toContain("import { supabaseClient } from '$lib/connections'")
961
+ // Shared client emitted; NO builtin cookie-session scaffold.
962
+ expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
963
+ expect(files.find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
964
+ expect(files.find((f) => f.path === 'src/lib/server/users.ts')).toBeUndefined()
965
+ expect(files.find((f) => f.path === 'src/routes/login/+page.svelte')).toBeUndefined()
966
+ // No cosmetic fake "Sign out" in the account menu - SvAuthGate's own bar owns it.
967
+ expect(layout).not.toMatch(/menuOpen = false\)}>Sign out/)
968
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
969
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
970
+ }
971
+ })
972
+
973
+ it('Supabase Auth emits the shared client even with no Supabase-bound entity', () => {
974
+ let p = createProject([customers]) // stays in-memory
975
+ p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anon' } }
976
+ p = setAuth(p, { enabled: true, provider: 'supabase' })
977
+ const files = emitStudioProject(p)
978
+ expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
979
+ expect(files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents).toContain('<SvAuthGate')
980
+ // data.ts does NOT import the client (no Supabase data entity uses it).
981
+ expect(files.find((f) => f.path === 'src/lib/data.ts')!.contents).not.toContain("from './connections'")
982
+ })
983
+
638
984
  it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
639
985
  let p = createProject([customers, orders])
640
986
  p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
@@ -1034,6 +1380,49 @@ describe('Custom actions', () => {
1034
1380
  })
1035
1381
  })
1036
1382
 
1383
+ describe('form/dialog layout depth', () => {
1384
+ const gridScreen = (extra: Partial<GridConfig>) => {
1385
+ let p = createProject([customers])
1386
+ const sid = p.screens[0]!.id
1387
+ const gid = p.screens[0]!.blocks[0]!.id
1388
+ p = updateBlock(p, sid, gid, { config: { editing: 'form', ...extra } as Partial<GridConfig> })
1389
+ return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1390
+ }
1391
+
1392
+ const panelLine = (page: string) => page.split('\n').find((l) => l.includes('<SvGridEditPanel schema='))!
1393
+
1394
+ it('emits nothing extra for a default form', () => {
1395
+ const line = panelLine(gridScreen({}))
1396
+ expect(line).toMatch(/<SvGridEditPanel schema=\{customersSchema\} row=\{editing\}/)
1397
+ expect(line).not.toMatch(/columns=\{/)
1398
+ expect(line).not.toMatch(/sections=\{/)
1399
+ })
1400
+
1401
+ it('passes columns / field selection+order / sections / title / size to the panel', () => {
1402
+ const page = gridScreen({
1403
+ formColumns: 3,
1404
+ formFields: ['name', 'email'],
1405
+ formSections: [{ title: 'Contact', fields: ['name', 'email'] }],
1406
+ formTitle: 'Edit customer',
1407
+ formSize: 'lg',
1408
+ })
1409
+ const line = panelLine(page)
1410
+ expect(line).toMatch(/columns=\{3\}/)
1411
+ expect(line).toMatch(/formFields=\{\["name","email"\]\}/)
1412
+ expect(line).toMatch(/sections=\{\[\{"title":"Contact","fields":\["name","email"\]\}\]\}/)
1413
+ expect(line).toMatch(/title=\{'Edit customer'\}/)
1414
+ expect(line).toMatch(/formSize='lg'/)
1415
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
1416
+ })
1417
+
1418
+ it('omits default-valued knobs (columns 2, size md)', () => {
1419
+ const line = panelLine(gridScreen({ formColumns: 2, formSize: 'md', formFields: ['name'] }))
1420
+ expect(line).not.toMatch(/columns=\{/)
1421
+ expect(line).not.toMatch(/formSize=/)
1422
+ expect(line).toMatch(/formFields=\{\["name"\]\}/)
1423
+ })
1424
+ })
1425
+
1037
1426
  describe('Component blocks', () => {
1038
1427
  it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
1039
1428
  let p = createProject([customers])
@@ -1044,7 +1433,10 @@ describe('Component blocks', () => {
1044
1433
  const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1045
1434
  const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
1046
1435
  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>")
1436
+ // Full extracted surface: curated attrs first, then the rest of the component's
1437
+ // real props (e.g. type={'button'}); content children last.
1438
+ expect(page).toContain("<SvButton variant={'primary'} size={'md'} block")
1439
+ expect(page).toContain(">{'Click me'}</SvButton>")
1048
1440
 
1049
1441
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
1050
1442
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
@@ -1060,8 +1452,10 @@ describe('Component blocks', () => {
1060
1452
 
1061
1453
  const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
1062
1454
  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>")
1455
+ expect(page).toContain("<SvButton variant={'secondary'} size={'md'}")
1456
+ expect(page).toContain(">{'Click me'}</SvButton>")
1457
+ expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill")
1458
+ expect(page).toContain(">{'Badge'}</SvBadge>")
1065
1459
  expect(page).not.toContain('Add your own content here')
1066
1460
 
1067
1461
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
@@ -1180,6 +1574,41 @@ describe('data-source codegen', () => {
1180
1574
  expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
1181
1575
  })
1182
1576
 
1577
+ it('REST adapter (dummyjson): emits real server paging via the adapter + imports it', () => {
1578
+ let p = createProject([customers])
1579
+ p = setEntityDataSource(p, 'customers', {
1580
+ kind: 'rest', baseUrl: 'https://dummyjson.com', path: 'users', method: 'GET', params: [],
1581
+ adapter: { kind: 'dummyjson', rowsKey: 'users' },
1582
+ })
1583
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1584
+ expect(dataTs).toContain('dummyJsonAdapter') // imported from @svgrid/enterprise
1585
+ expect(dataTs).toMatch(/\.\.\.dummyJsonAdapter<\w+>\('users'\)/)
1586
+ })
1587
+
1588
+ it('REST offsetLimit adapter emits only the configured params', () => {
1589
+ let p = createProject([customers])
1590
+ p = setEntityDataSource(p, 'customers', {
1591
+ kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
1592
+ adapter: { kind: 'offsetLimit', offsetParam: '_start', limitParam: '_limit', sortByParam: 'sort' },
1593
+ })
1594
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1595
+ expect(dataTs).toContain("offsetParam: '_start'")
1596
+ expect(dataTs).toContain("limitParam: '_limit'")
1597
+ expect(dataTs).toContain("sortByParam: 'sort'")
1598
+ expect(dataTs).not.toContain('orderParam') // unset fields are omitted
1599
+ })
1600
+
1601
+ it('a REST adapter supersedes the manual rowsPath/totalPath parse', () => {
1602
+ let p = createProject([customers])
1603
+ p = setEntityDataSource(p, 'customers', {
1604
+ kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
1605
+ adapter: { kind: 'jsonServer' }, rowsPath: 'data', totalPath: 'total',
1606
+ })
1607
+ const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
1608
+ expect(dataTs).toContain('jsonServerAdapter')
1609
+ expect(dataTs).not.toContain('parse: (body: any)') // adapter provides parse instead
1610
+ })
1611
+
1183
1612
  it('substitutes static path params into the URL', () => {
1184
1613
  let p = createProject([customers])
1185
1614
  p = setEntityDataSource(p, 'customers', {
@@ -1245,20 +1674,30 @@ describe('data-source codegen', () => {
1245
1674
  expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
1246
1675
  })
1247
1676
 
1248
- it('emits a real Supabase client from a project URL + anon key', () => {
1677
+ it('emits a Supabase client that reads PUBLIC_SUPABASE_* env vars (never inlines the key)', () => {
1249
1678
  let p = createProject([customers])
1250
1679
  p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
1251
1680
  const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
1252
1681
  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', () => {
1682
+ expect(conn).toContain("import { env } from '$env/dynamic/public'")
1683
+ expect(conn).toContain('createClient(')
1684
+ expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
1685
+ expect(conn).toContain('env.PUBLIC_SUPABASE_ANON_KEY')
1686
+ // The anon key is NEVER baked into source (it goes to .env); the designer values
1687
+ // appear only as a paste-into-.env comment.
1688
+ expect(conn).not.toContain("createClient('https://x.supabase.co'")
1689
+ // .env.example lists the public Supabase keys.
1690
+ const envEx = emitStudioAppBundle(p).find((f) => f.path === '.env.example')!.contents
1691
+ expect(envEx).toContain('PUBLIC_SUPABASE_URL=')
1692
+ expect(envEx).toContain('PUBLIC_SUPABASE_ANON_KEY=')
1693
+ })
1694
+
1695
+ it('still emits an env-var Supabase client without designer url/key', () => {
1257
1696
  let p = createProject([customers])
1258
1697
  p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1259
1698
  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
1699
+ expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
1700
+ expect(conn).not.toContain('null as unknown as SupabaseClientLike') // no stub - reads env
1262
1701
  })
1263
1702
 
1264
1703
  it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
@@ -1269,6 +1708,50 @@ describe('data-source codegen', () => {
1269
1708
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
1270
1709
  }
1271
1710
  })
1711
+
1712
+ it('Supabase realtime: subscribes the grid page to live changes and refreshes on change', () => {
1713
+ let p = createProject([customers])
1714
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', realtime: true })
1715
+ const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
1716
+ expect(page).toContain('createSupabaseRealtime')
1717
+ expect(page).toContain("import { supabaseClient } from '$lib/connections'")
1718
+ expect(page).toContain("table: 'customers'")
1719
+ expect(page).toContain('onChange: () => controller.refresh()')
1720
+ // Cleanup unsubscribes the channel alongside disposing the controller.
1721
+ expect(page).toContain('__rt.unsubscribe()')
1722
+ expect(page).toContain('controller.dispose()')
1723
+ // Compiles.
1724
+ expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
1725
+ })
1726
+
1727
+ it('Supabase without realtime does NOT emit a subscription', () => {
1728
+ let p = createProject([customers])
1729
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1730
+ const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
1731
+ expect(page).not.toContain('createSupabaseRealtime')
1732
+ })
1733
+
1734
+ it('project-level Supabase connection feeds the .env paste hint (entity carries only the table)', () => {
1735
+ let p = createProject([customers])
1736
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
1737
+ p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anonKEY' } }
1738
+ const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
1739
+ expect(conn).toContain('PUBLIC_SUPABASE_URL=https://proj.supabase.co')
1740
+ expect(conn).toContain('PUBLIC_SUPABASE_ANON_KEY=anonKEY')
1741
+ // The client still reads env (project-level values only seed the paste comment).
1742
+ expect(conn).not.toContain("createClient('https://proj.supabase.co'")
1743
+ })
1744
+
1745
+ it('SQL schema selector qualifies the table in the generated route', () => {
1746
+ let p = createProject([customers])
1747
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres', schema: 'analytics' })
1748
+ const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!.contents
1749
+ expect(route).toContain('dbSchema: "analytics"')
1750
+ // No schema set -> no dbSchema option (unqualified table).
1751
+ let q = createProject([customers])
1752
+ q = setEntityDataSource(q, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
1753
+ expect(byPathOf(emitStudioProject(q), 'src/routes/api/customers/+server.ts')!.contents).not.toContain('dbSchema:')
1754
+ })
1272
1755
  })
1273
1756
 
1274
1757
  describe('grid editing (a Grid property) -> codegen', () => {
@@ -1710,7 +2193,7 @@ describe('dock layout (screen.layout = dock)', () => {
1710
2193
  const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
1711
2194
  expect(page).toContain('async function save({ mode, id, values }')
1712
2195
  expect(page).toContain('const row = values')
1713
- expect(page).toContain('const ctx = { grid: gridApi!,')
2196
+ expect(page).toContain('const ctx = { grid: gridCtx,')
1714
2197
  expect(page).toContain("ctx.goto('/customers')")
1715
2198
  for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
1716
2199
  expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
@@ -2043,7 +2526,7 @@ describe('code companion (design + your own code)', () => {
2043
2526
  expect(page.contents).toContain("import * as handlers from './handlers'")
2044
2527
  // onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
2045
2528
  // 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) }')
2529
+ expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
2047
2530
  expect(page.contents).toContain('as PageContext')
2048
2531
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2049
2532
  expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
@@ -2074,10 +2557,12 @@ describe('code companion (design + your own code)', () => {
2074
2557
  const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
2075
2558
  // Named handle (button1) created + wired into the component markup.
2076
2559
  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'")
2560
+ // Click is wired through the component's own onclick callback prop (exact
2561
+ // semantics), not a bubbling wrapper listener.
2562
+ expect(page.contents).toContain("<SvButton {...button1.props} onclick={(e) => button1.fire('click', e)}>{button1.text}</SvButton>")
2563
+ expect(page.contents).toContain("import { handle, gridHandle } from '$lib/handles.svelte'")
2079
2564
  expect(page.contents).toContain('button1.fire(\'click\', e)')
2080
- expect(page.contents).toContain('const ctx = { grid: gridApi!, button1, data:')
2565
+ expect(page.contents).toContain('const ctx = { grid: gridCtx, button1, data:')
2081
2566
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2082
2567
  // Typed handle: ButtonHandle intersects Handle with the button's real setters.
2083
2568
  expect(ctx.contents).toContain('button1: ButtonHandle')
@@ -2177,7 +2662,7 @@ describe('code companion (design + your own code)', () => {
2177
2662
  expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
2178
2663
  expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
2179
2664
  // 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')
2665
+ 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
2666
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2182
2667
  expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
2183
2668
  // The grid api is typed to the entity's row (Customers), not any.
@@ -2203,7 +2688,7 @@ describe('code companion (design + your own code)', () => {
2203
2688
  expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
2204
2689
  expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
2205
2690
  expect(page.contents).toContain('rows={chart1.rows}')
2206
- expect(page.contents).toContain("import { handle, dataHandle } from '$lib/handles.svelte'")
2691
+ expect(page.contents).toContain("import { handle, dataHandle, gridHandle } from '$lib/handles.svelte'")
2207
2692
  // ctx carries the data handles + the entity component handle + batteries.
2208
2693
  expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
2209
2694
  expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')