@svgrid/enterprise 2.3.0 → 2.3.2

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 (73) hide show
  1. package/dist/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
  2. package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
  3. package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
  4. package/dist/cdn/svgrid-enterprise.svelte-external.js +11308 -7898
  5. package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
  6. package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
  7. package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
  8. package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
  9. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
  10. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
  11. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
  12. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
  13. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  14. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
  15. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
  16. package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
  17. package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
  18. package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
  19. package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
  20. package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
  21. package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
  22. package/dist/designer/assets/easing-zaypKrr0.js +3 -0
  23. package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
  24. package/dist/designer/assets/index-DVrv9dH6.js +75797 -0
  25. package/dist/designer/assets/index-DjPp1mn7.css +1 -0
  26. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
  27. package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
  28. package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
  29. package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
  30. package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  31. package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
  32. package/dist/designer/assets/src-BRKu53tV.js +2842 -0
  33. package/dist/designer/assets/src-DseB1VRN.css +1 -0
  34. package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
  35. package/dist/designer/index.html +32 -0
  36. package/dist/node/studio.js +16778 -12672
  37. package/package.json +10 -7
  38. package/src/SvGridBoard.svelte +4 -1
  39. package/src/SvGridScheduler.svelte +114 -90
  40. package/src/ai-export-pdf.dom.test.ts +77 -77
  41. package/src/ai-export-xlsx.dom.test.ts +90 -90
  42. package/src/ai-export.dom.test.ts +114 -114
  43. package/src/export-ooxml.ts +4 -0
  44. package/src/export-xls.ts +3 -0
  45. package/src/expressions/evaluate.ts +9 -0
  46. package/src/import.test.ts +1 -1
  47. package/src/import.ts +1 -1
  48. package/src/index.ts +12 -0
  49. package/src/pivot.test.ts +0 -1
  50. package/src/schema-designer.ts +1 -1
  51. package/src/sources/introspect-supabase.ts +2 -2
  52. package/src/studio/emit-project.test.ts +298 -7
  53. package/src/studio/emit-project.ts +484 -30
  54. package/src/studio/emit-schema.ts +20 -10
  55. package/src/studio/index.ts +34 -0
  56. package/src/studio/init-flow.test.ts +364 -0
  57. package/src/studio/init-flow.ts +409 -0
  58. package/src/studio/project.test.ts +0 -1
  59. package/src/studio/project.ts +109 -2
  60. package/src/studio/samples/datasets.test.ts +84 -0
  61. package/src/studio/samples/datasets.ts +340 -0
  62. package/src/studio/samples/live-data.test.ts +1 -1
  63. package/src/studio/samples/samples.test.ts +268 -268
  64. package/src/studio/samples/shared.ts +7 -150
  65. package/src/studio/screen-suites.test.ts +226 -0
  66. package/src/studio/screen-suites.ts +445 -0
  67. package/src/studio/ui-components-surface.test.ts +2 -2
  68. package/src/studio/ui-components.generated.ts +1643 -106
  69. package/src/studio/ui-components.ts +742 -641
  70. package/src/sveltekit/index.ts +1 -0
  71. package/src/sveltekit/sql-source.ts +1 -1
  72. package/src/sveltekit/transport-scope.test.ts +125 -0
  73. package/src/sveltekit/transport.ts +76 -2
@@ -1,7 +1,7 @@
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
6
  import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
7
7
  import { UI_COMPONENT_REGISTRY } from './ui-components'
@@ -54,6 +54,26 @@ describe('SSR-native screens (renderMode: ssr)', () => {
54
54
  return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
55
55
  }
56
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
+
57
77
  it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
58
78
  const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
59
79
  expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
@@ -722,7 +742,7 @@ describe('emitStudioProject (per-block screens)', () => {
722
742
  expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
723
743
  // The shell wires the session in: login bypass, real user, sign-out, role seed.
724
744
  const layout = get('src/routes/+layout.svelte')!
725
- 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)
726
746
  expect(layout).toContain('action="/logout"')
727
747
  expect(layout).toContain('currentRole.set(data.role')
728
748
  expect(layout).toContain('data?.user?.email')
@@ -787,7 +807,7 @@ describe('emitStudioProject (per-block screens)', () => {
787
807
 
788
808
  it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
789
809
  // Off by default.
790
- 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' })
791
811
  expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
792
812
  // Toggle on but no SQL entity (memory source) -> nothing.
793
813
  expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
@@ -1011,6 +1031,191 @@ describe('emitStudioProject (per-block screens)', () => {
1011
1031
  expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
1012
1032
  })
1013
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
+
1014
1219
  it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
1015
1220
  let p = createProject([customers, orders])
1016
1221
  const cid = p.screens.find((s) => s.entity === 'customers')!.id
@@ -1233,7 +1438,7 @@ describe('emitStudioProject (per-block screens)', () => {
1233
1438
  expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
1234
1439
 
1235
1440
  const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
1236
- 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
1237
1442
  expect(ord).toContain('sp.get(_f)')
1238
1443
 
1239
1444
  for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
@@ -1251,7 +1456,15 @@ describe('emitStudioProject (per-block screens)', () => {
1251
1456
  expect(cat).toContain('export function localizeCols')
1252
1457
  expect(cat).toContain('"col.customers.name"') // seeded from the schema
1253
1458
  expect(cat).toContain('"nav.customers"')
1254
- 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))
1255
1468
 
1256
1469
  const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
1257
1470
  expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
@@ -1982,6 +2195,50 @@ describe('grid editing (a Grid property) -> codegen', () => {
1982
2195
  expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
1983
2196
  })
1984
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
+
1985
2242
  it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
1986
2243
  let p = createProject([customers])
1987
2244
  const sid = p.screens[0]!.id
@@ -2338,6 +2595,35 @@ describe('emitStudioAppBundle (full runnable app)', () => {
2338
2595
  expect(pkg.devDependencies['vite']).toBeTruthy()
2339
2596
  })
2340
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
+
2341
2627
  it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
2342
2628
  const bundle = emitStudioAppBundle(createProject([customers]))
2343
2629
  const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
@@ -2662,7 +2948,7 @@ describe('code companion (design + your own code)', () => {
2662
2948
  expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
2663
2949
  expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
2664
2950
  // Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
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')
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')
2666
2952
  expect(page.contents).toContain('handlers.onLoad(ctx)')
2667
2953
  expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
2668
2954
  // The grid api is typed to the entity's row (Customers), not any.
@@ -2828,7 +3114,12 @@ describe('code companion (design + your own code)', () => {
2828
3114
  expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
2829
3115
  // An unknown ctx member is caught.
2830
3116
  expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
2831
- })
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)
2832
3123
 
2833
3124
  it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
2834
3125
  let p = createProject([customers])