@svgrid/enterprise 2.0.3 → 2.2.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 (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
  3. package/dist/node/studio.js +7889 -2460
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. package/dist/designer/index.html +0 -13
@@ -1,308 +1,308 @@
1
- /**
2
- * "Start from live data" gallery: starter `StudioProject`s wired to a REAL data
3
- * source instead of an in-memory seed - so one click gives you an app that talks
4
- * to actual Postgres or a live API, then generates a runnable project bound to
5
- * the same source.
6
- *
7
- * These are deliberately separate from `sampleApps` (which are polished,
8
- * memory-backed, enterprise-layout showcases). A live-data starter's value is
9
- * the *binding*: embedded Postgres (PGlite), a hosted Supabase project, or a
10
- * public REST API.
11
- *
12
- * Pure + node-safe: plain data built from the same ops the designer uses, so
13
- * each round-trips through parse/serialize and generates like any project.
14
- */
15
- import type { EntitySchema } from '../../schema.js'
16
- import { listScreen, dashScreen, detailScreen, type SampleApp } from './shared.js'
17
- import { sanitizeProject, type EntityDataSource, type Screen, type StudioProject } from '../project.js'
18
-
19
- type Row = Record<string, unknown>
20
-
21
- // ---- Northwind schemas (shared by the PGlite + Supabase starters) ----------
22
-
23
- const products: EntitySchema = {
24
- name: 'products',
25
- label: 'Product',
26
- idField: 'id',
27
- fields: [
28
- { field: 'id', type: 'number', primaryKey: true, readonly: true },
29
- { field: 'name', type: 'text', label: 'Product', required: true, minLength: 2 },
30
- { field: 'category_id', type: 'number', label: 'Category #', min: 1, max: 8 },
31
- { field: 'unit_price', type: 'number', label: 'Unit Price ($)', min: 0 },
32
- { field: 'units_in_stock', type: 'number', label: 'In Stock', min: 0 },
33
- { field: 'discontinued', type: 'boolean' },
34
- ],
35
- }
36
-
37
- const customers: EntitySchema = {
38
- name: 'customers',
39
- label: 'Customer',
40
- idField: 'id',
41
- fields: [
42
- { field: 'id', type: 'number', primaryKey: true, readonly: true },
43
- { field: 'company', type: 'text', label: 'Company', required: true, minLength: 2 },
44
- { field: 'contact', type: 'text', label: 'Contact' },
45
- { field: 'city', type: 'text', label: 'City' },
46
- { field: 'country', type: 'text', label: 'Country' },
47
- ],
48
- }
49
-
50
- /** The `order_lines` join view (Supabase) - read-only. */
51
- const orderLines: EntitySchema = {
52
- name: 'order_lines',
53
- label: 'Order line',
54
- idField: 'id',
55
- fields: [
56
- { field: 'id', type: 'number', primaryKey: true, readonly: true },
57
- { field: 'order_id', type: 'number', label: 'Order #', readonly: true },
58
- { field: 'order_date', type: 'dateString', label: 'Date', readonly: true },
59
- { field: 'customer', type: 'text', label: 'Customer', readonly: true },
60
- { field: 'product', type: 'text', label: 'Product', readonly: true },
61
- { field: 'category', type: 'text', label: 'Category', readonly: true },
62
- { field: 'quantity', type: 'number', label: 'Qty', readonly: true },
63
- { field: 'unit_price', type: 'number', label: 'Unit Price ($)', readonly: true },
64
- { field: 'line_total', type: 'number', label: 'Line Total ($)', readonly: true },
65
- { field: 'ship_country', type: 'text', label: 'Ship To', readonly: true },
66
- ],
67
- }
68
-
69
- // ---- Curated Northwind seed (for the PGlite starter) ------------------------
70
-
71
- const PRODUCTS: Row[] = [
72
- { id: 1, name: 'Chai', category_id: 1, unit_price: 18, units_in_stock: 39, discontinued: false },
73
- { id: 2, name: 'Chang', category_id: 1, unit_price: 19, units_in_stock: 17, discontinued: false },
74
- { id: 3, name: 'Aniseed Syrup', category_id: 2, unit_price: 10, units_in_stock: 13, discontinued: false },
75
- { id: 4, name: "Chef Anton's Cajun Seasoning", category_id: 2, unit_price: 22, units_in_stock: 53, discontinued: false },
76
- { id: 5, name: "Grandma's Boysenberry Spread", category_id: 2, unit_price: 25, units_in_stock: 120, discontinued: false },
77
- { id: 6, name: "Uncle Bob's Organic Dried Pears", category_id: 7, unit_price: 30, units_in_stock: 15, discontinued: false },
78
- { id: 7, name: 'Northwoods Cranberry Sauce', category_id: 2, unit_price: 40, units_in_stock: 6, discontinued: false },
79
- { id: 8, name: 'Mishi Kobe Niku', category_id: 6, unit_price: 97, units_in_stock: 29, discontinued: true },
80
- { id: 9, name: 'Ikura', category_id: 8, unit_price: 31, units_in_stock: 31, discontinued: false },
81
- { id: 10, name: 'Queso Cabrales', category_id: 4, unit_price: 21, units_in_stock: 22, discontinued: false },
82
- { id: 11, name: 'Konbu', category_id: 8, unit_price: 6, units_in_stock: 24, discontinued: false },
83
- { id: 12, name: 'Tofu', category_id: 7, unit_price: 23.25, units_in_stock: 35, discontinued: false },
84
- { id: 13, name: 'Genen Shouyu', category_id: 2, unit_price: 15.5, units_in_stock: 39, discontinued: false },
85
- { id: 14, name: 'Pavlova', category_id: 3, unit_price: 17.45, units_in_stock: 29, discontinued: false },
86
- { id: 15, name: 'Alice Mutton', category_id: 6, unit_price: 39, units_in_stock: 0, discontinued: true },
87
- { id: 16, name: 'Carnarvon Tigers', category_id: 8, unit_price: 62.5, units_in_stock: 42, discontinued: false },
88
- ]
89
-
90
- const CUSTOMERS: Row[] = [
91
- { id: 1, company: 'Alfreds Futterkiste', contact: 'Maria Anders', city: 'Berlin', country: 'Germany' },
92
- { id: 2, company: 'Ana Trujillo Emparedados', contact: 'Ana Trujillo', city: 'México D.F.', country: 'Mexico' },
93
- { id: 3, company: 'Antonio Moreno Taquería', contact: 'Antonio Moreno', city: 'México D.F.', country: 'Mexico' },
94
- { id: 4, company: 'Around the Horn', contact: 'Thomas Hardy', city: 'London', country: 'UK' },
95
- { id: 5, company: 'Berglunds snabbköp', contact: 'Christina Berglund', city: 'Luleå', country: 'Sweden' },
96
- { id: 6, company: 'Blauer See Delikatessen', contact: 'Hanna Moos', city: 'Mannheim', country: 'Germany' },
97
- { id: 7, company: 'Bólido Comidas preparadas', contact: 'Martín Sommer', city: 'Madrid', country: 'Spain' },
98
- { id: 8, company: "Bon app'", contact: 'Laurence Lebihan', city: 'Marseille', country: 'France' },
99
- { id: 9, company: 'Bottom-Dollar Markets', contact: 'Elizabeth Lincoln', city: 'Tsawassen', country: 'Canada' },
100
- { id: 10, company: "B's Beverages", contact: 'Victoria Ashworth', city: 'London', country: 'UK' },
101
- ]
102
-
103
- // ---- Hosted Supabase sample (see docs/enterprise/studio/supabase-sample.md) --
104
- // Read-only anon RLS, so the publishable key is safe to embed.
105
- const SUPABASE_URL = 'https://rbnnlzgtfzsylllniozo.supabase.co'
106
- const SUPABASE_KEY = 'sb_publishable_SiT7CxGl4Z_Du1opTYbxVA_VQWumPUN'
107
-
108
- /** Assemble a starter project from entities + screens + explicit per-entity sources. */
109
- function assemble(opts: {
110
- title: string
111
- brand: string
112
- accent: string
113
- mode?: 'light' | 'dark'
114
- preset?: string
115
- navStyle?: 'sidebar' | 'top-nav'
116
- entities: EntitySchema[]
117
- screens: Screen[]
118
- sources: Record<string, EntityDataSource>
119
- dataSource: StudioProject['dataSource']
120
- }): StudioProject {
121
- return sanitizeProject({
122
- title: opts.title,
123
- entities: opts.entities,
124
- screens: opts.screens,
125
- dataSource: opts.dataSource,
126
- dataSources: opts.sources,
127
- theme: {
128
- accent: opts.accent,
129
- ...(opts.mode ? { mode: opts.mode } : {}),
130
- ...(opts.preset ? { preset: opts.preset } : {}),
131
- shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: '', navPosition: 'left' },
132
- },
133
- })
134
- }
135
-
136
- // ---- Starters ---------------------------------------------------------------
137
-
138
- /** Northwind on embedded Postgres (PGlite) - real SQL + CRUD, zero backend. */
139
- const northwindPglite: SampleApp = {
140
- id: 'live-northwind-pglite',
141
- name: 'Northwind · Local Postgres',
142
- description: 'The Northwind sample on a real in-browser Postgres (PGlite). Full CRUD, no server, no keys.',
143
- emoji: '🐘',
144
- accent: '#2f6f4f',
145
- build: () =>
146
- assemble({
147
- title: 'Northwind (Local Postgres)',
148
- brand: 'Northwind',
149
- accent: '#2f6f4f',
150
- preset: 'material',
151
- dataSource: 'pglite',
152
- entities: [products, customers],
153
- screens: [
154
- dashScreen(products, { id: 'overview', title: 'Overview', order: 0 }, [
155
- { kpi: 'Products', reduce: 'count', span: 1 },
156
- { kpi: 'Avg price', measure: 'unit_price', reduce: 'avg', format: 'currency', span: 1 },
157
- { kpi: 'In stock', measure: 'units_in_stock', reduce: 'sum', span: 1 },
158
- { kpi: 'Priciest', measure: 'unit_price', reduce: 'max', format: 'currency', span: 1 },
159
- { chart: 'category_id', measure: 'unit_price', reduce: 'sum', type: 'bar', span: 2 },
160
- { chart: 'discontinued', reduce: 'count', type: 'pie', span: 1 },
161
- { grid: true, rowLink: { screen: 'product-detail', sourceField: 'id', targetField: 'id' }, span: 3 },
162
- ]),
163
- detailScreen(products, { id: 'product-detail', title: 'Product', order: 1 }, {
164
- titleField: 'name', metricFields: ['unit_price', 'units_in_stock'],
165
- sections: [{ label: 'Catalog', fields: ['category_id', 'discontinued'] }],
166
- }),
167
- listScreen(customers, { id: 'customers', title: 'Customers', order: 2 }, { grid: { rowLink: { screen: 'customer-detail', sourceField: 'id', targetField: 'id' } } }),
168
- detailScreen(customers, { id: 'customer-detail', title: 'Customer', order: 3 }, {
169
- titleField: 'company', subtitleField: 'contact',
170
- sections: [{ label: 'Location', fields: ['city', 'country'] }],
171
- }),
172
- ],
173
- sources: {
174
- products: { kind: 'pglite', table: 'products', seed: PRODUCTS },
175
- customers: { kind: 'pglite', table: 'customers', seed: CUSTOMERS },
176
- },
177
- }),
178
- }
179
-
180
- /** Northwind on a hosted Supabase project - live PostgREST, incl. the join view. */
181
- const northwindSupabase: SampleApp = {
182
- id: 'live-northwind-supabase',
183
- name: 'Northwind · Supabase',
184
- description: 'A hosted, read-only Northwind on Supabase. Browse the five-table order_lines join view live over PostgREST.',
185
- emoji: '⚡',
186
- accent: '#3ecf8e',
187
- build: () =>
188
- assemble({
189
- title: 'Northwind (Supabase)',
190
- brand: 'Northwind',
191
- accent: '#3ecf8e',
192
- mode: 'dark',
193
- preset: 'vercel',
194
- dataSource: 'supabase',
195
- entities: [orderLines, products, customers],
196
- screens: [
197
- // Sales dashboard over the join view: revenue KPIs + breakdowns by category /
198
- // country / product, a faceted filter, and the raw order-lines grid.
199
- dashScreen(orderLines, { id: 'sales', title: 'Sales', order: 0 }, [
200
- { kpi: 'Line items', reduce: 'count', span: 1 },
201
- { kpi: 'Revenue', measure: 'line_total', reduce: 'sum', format: 'currency', span: 1 },
202
- { kpi: 'Avg line', measure: 'line_total', reduce: 'avg', format: 'currency', span: 1 },
203
- { kpi: 'Units sold', measure: 'quantity', reduce: 'sum', span: 1 },
204
- { chart: 'category', measure: 'line_total', reduce: 'sum', type: 'bar', span: 2 },
205
- { chart: 'ship_country', measure: 'line_total', reduce: 'sum', type: 'pie', span: 1 },
206
- { chart: 'product', measure: 'line_total', reduce: 'sum', type: 'bar', span: 3 },
207
- { filter: ['category', 'ship_country'], span: 3 },
208
- { grid: true, span: 3 },
209
- ]),
210
- listScreen(products, { id: 'products', title: 'Products', order: 1 }, { grid: { rowLink: { screen: 'product-360', sourceField: 'id', targetField: 'id' } } }),
211
- // Product 360: unit price / stock tiles + a timeline of the order lines for
212
- // this product. The view has no FK ids, so the timeline matches by name.
213
- detailScreen(products, { id: 'product-360', title: 'Product', order: 2 }, {
214
- titleField: 'name', metricFields: ['unit_price', 'units_in_stock'],
215
- sections: [{ label: 'Catalog', fields: ['category_id', 'discontinued'] }],
216
- related: [{ entity: 'order_lines', foreignKey: 'product', parentField: 'name', label: 'Orders', titleField: 'customer', subtitleField: 'line_total', dateField: 'order_date' }],
217
- }),
218
- listScreen(customers, { id: 'customers', title: 'Customers', order: 3 }, { grid: { rowLink: { screen: 'customer-360', sourceField: 'id', targetField: 'id' } } }),
219
- // Customer 360: an order-history timeline matched by company name.
220
- detailScreen(customers, { id: 'customer-360', title: 'Customer', order: 4 }, {
221
- titleField: 'company', subtitleField: 'contact',
222
- sections: [{ label: 'Location', fields: ['city', 'country'] }],
223
- related: [{ entity: 'order_lines', foreignKey: 'customer', parentField: 'company', label: 'Orders', titleField: 'product', subtitleField: 'line_total', dateField: 'order_date' }],
224
- }),
225
- ],
226
- sources: {
227
- order_lines: { kind: 'supabase', table: 'order_lines', url: SUPABASE_URL, key: SUPABASE_KEY },
228
- products: { kind: 'supabase', table: 'products', url: SUPABASE_URL, key: SUPABASE_KEY },
229
- customers: { kind: 'supabase', table: 'customers', url: SUPABASE_URL, key: SUPABASE_KEY },
230
- },
231
- }),
232
- }
233
-
234
- /** A live public REST feed (DummyJSON products) - no backend, no keys. */
235
- const dummyjsonRest: SampleApp = {
236
- id: 'live-dummyjson-rest',
237
- name: 'Products · Live REST',
238
- description: 'A live product feed from the public DummyJSON API over createRestDataSource. No backend, no keys.',
239
- emoji: '🛰️',
240
- accent: '#6d5efc',
241
- build: () => {
242
- const restProduct: EntitySchema = {
243
- name: 'products',
244
- label: 'Product',
245
- idField: 'id',
246
- fields: [
247
- { field: 'id', type: 'number', primaryKey: true, readonly: true },
248
- { field: 'title', type: 'text', label: 'Product' },
249
- { field: 'brand', type: 'text', label: 'Brand' },
250
- { field: 'category', type: 'text', label: 'Category' },
251
- { field: 'price', type: 'number', label: 'Price ($)' },
252
- { field: 'rating', type: 'number', label: 'Rating' },
253
- { field: 'stock', type: 'number', label: 'Stock' },
254
- ],
255
- }
256
- return assemble({
257
- title: 'Live Products (REST)',
258
- brand: 'Live Feed',
259
- accent: '#6d5efc',
260
- preset: 'shadcn',
261
- dataSource: 'rest',
262
- entities: [restProduct],
263
- // A page size that covers the whole catalogue: the grid asks for one page,
264
- // so it reads the entire live feed in a single request. (DummyJSON pages by
265
- // `skip`, not `offset`, so deep server-paging would need a REST adapter -
266
- // see docs/enterprise/studio/rest-api.md.)
267
- screens: [
268
- // Catalog dashboard: price / rating / stock KPIs + category & brand breakdowns
269
- // over the live feed, with the grid drilling into a product page.
270
- dashScreen(restProduct, { id: 'catalog', title: 'Catalog', order: 0 }, [
271
- { kpi: 'Products', reduce: 'count', span: 1 },
272
- { kpi: 'Avg price', measure: 'price', reduce: 'avg', format: 'currency', span: 1 },
273
- { kpi: 'Avg rating', measure: 'rating', reduce: 'avg', span: 1 },
274
- { kpi: 'In stock', measure: 'stock', reduce: 'sum', span: 1 },
275
- { chart: 'category', reduce: 'count', type: 'bar', span: 2 },
276
- { chart: 'brand', measure: 'price', reduce: 'avg', type: 'bar', span: 1 },
277
- { filter: ['category', 'brand'], span: 3 },
278
- { grid: true, pageSize: 200, rowLink: { screen: 'product-detail', sourceField: 'id', targetField: 'id' }, span: 3 },
279
- ]),
280
- detailScreen(restProduct, { id: 'product-detail', title: 'Product', order: 1 }, {
281
- titleField: 'title', subtitleField: 'brand', metricFields: ['price', 'rating', 'stock'],
282
- sections: [{ label: 'Details', fields: ['category', 'brand'] }],
283
- }),
284
- ],
285
- sources: {
286
- // Rows + total read from DummyJSON's { products, total } envelope. `limit=0`
287
- // asks DummyJSON for the whole catalogue so the dashboard aggregates it all.
288
- products: {
289
- kind: 'rest',
290
- baseUrl: 'https://dummyjson.com',
291
- path: 'products',
292
- method: 'GET',
293
- params: [{ name: 'limit', location: 'query', type: 'number', value: '0' }],
294
- rowsPath: 'products',
295
- totalPath: 'total',
296
- },
297
- },
298
- })
299
- },
300
- }
301
-
302
- /** Starters wired to a real data source, in gallery order. */
303
- export const liveDataSamples: SampleApp[] = [northwindPglite, northwindSupabase, dummyjsonRest]
304
-
305
- /** Look up a live-data starter by id. */
306
- export function getLiveDataSample(id: string): SampleApp | undefined {
307
- return liveDataSamples.find((s) => s.id === id)
308
- }
1
+ /**
2
+ * "Start from live data" gallery: starter `StudioProject`s wired to a REAL data
3
+ * source instead of an in-memory seed - so one click gives you an app that talks
4
+ * to actual Postgres or a live API, then generates a runnable project bound to
5
+ * the same source.
6
+ *
7
+ * These are deliberately separate from `sampleApps` (which are polished,
8
+ * memory-backed, enterprise-layout showcases). A live-data starter's value is
9
+ * the *binding*: embedded Postgres (PGlite), a hosted Supabase project, or a
10
+ * public REST API.
11
+ *
12
+ * Pure + node-safe: plain data built from the same ops the designer uses, so
13
+ * each round-trips through parse/serialize and generates like any project.
14
+ */
15
+ import type { EntitySchema } from '../../schema.js'
16
+ import { listScreen, dashScreen, detailScreen, type SampleApp } from './shared.js'
17
+ import { sanitizeProject, type EntityDataSource, type Screen, type StudioProject, type ShellStyle } from '../project.js'
18
+
19
+ type Row = Record<string, unknown>
20
+
21
+ // ---- Northwind schemas (shared by the PGlite + Supabase starters) ----------
22
+
23
+ const products: EntitySchema = {
24
+ name: 'products',
25
+ label: 'Product',
26
+ idField: 'id',
27
+ fields: [
28
+ { field: 'id', type: 'number', primaryKey: true, readonly: true },
29
+ { field: 'name', type: 'text', label: 'Product', required: true, minLength: 2 },
30
+ { field: 'category_id', type: 'number', label: 'Category #', min: 1, max: 8 },
31
+ { field: 'unit_price', type: 'number', label: 'Unit Price ($)', min: 0 },
32
+ { field: 'units_in_stock', type: 'number', label: 'In Stock', min: 0 },
33
+ { field: 'discontinued', type: 'boolean' },
34
+ ],
35
+ }
36
+
37
+ const customers: EntitySchema = {
38
+ name: 'customers',
39
+ label: 'Customer',
40
+ idField: 'id',
41
+ fields: [
42
+ { field: 'id', type: 'number', primaryKey: true, readonly: true },
43
+ { field: 'company', type: 'text', label: 'Company', required: true, minLength: 2 },
44
+ { field: 'contact', type: 'text', label: 'Contact' },
45
+ { field: 'city', type: 'text', label: 'City' },
46
+ { field: 'country', type: 'text', label: 'Country' },
47
+ ],
48
+ }
49
+
50
+ /** The `order_lines` join view (Supabase) - read-only. */
51
+ const orderLines: EntitySchema = {
52
+ name: 'order_lines',
53
+ label: 'Order line',
54
+ idField: 'id',
55
+ fields: [
56
+ { field: 'id', type: 'number', primaryKey: true, readonly: true },
57
+ { field: 'order_id', type: 'number', label: 'Order #', readonly: true },
58
+ { field: 'order_date', type: 'dateString', label: 'Date', readonly: true },
59
+ { field: 'customer', type: 'text', label: 'Customer', readonly: true },
60
+ { field: 'product', type: 'text', label: 'Product', readonly: true },
61
+ { field: 'category', type: 'text', label: 'Category', readonly: true },
62
+ { field: 'quantity', type: 'number', label: 'Qty', readonly: true },
63
+ { field: 'unit_price', type: 'number', label: 'Unit Price ($)', readonly: true },
64
+ { field: 'line_total', type: 'number', label: 'Line Total ($)', readonly: true },
65
+ { field: 'ship_country', type: 'text', label: 'Ship To', readonly: true },
66
+ ],
67
+ }
68
+
69
+ // ---- Curated Northwind seed (for the PGlite starter) ------------------------
70
+
71
+ const PRODUCTS: Row[] = [
72
+ { id: 1, name: 'Chai', category_id: 1, unit_price: 18, units_in_stock: 39, discontinued: false },
73
+ { id: 2, name: 'Chang', category_id: 1, unit_price: 19, units_in_stock: 17, discontinued: false },
74
+ { id: 3, name: 'Aniseed Syrup', category_id: 2, unit_price: 10, units_in_stock: 13, discontinued: false },
75
+ { id: 4, name: "Chef Anton's Cajun Seasoning", category_id: 2, unit_price: 22, units_in_stock: 53, discontinued: false },
76
+ { id: 5, name: "Grandma's Boysenberry Spread", category_id: 2, unit_price: 25, units_in_stock: 120, discontinued: false },
77
+ { id: 6, name: "Uncle Bob's Organic Dried Pears", category_id: 7, unit_price: 30, units_in_stock: 15, discontinued: false },
78
+ { id: 7, name: 'Northwoods Cranberry Sauce', category_id: 2, unit_price: 40, units_in_stock: 6, discontinued: false },
79
+ { id: 8, name: 'Mishi Kobe Niku', category_id: 6, unit_price: 97, units_in_stock: 29, discontinued: true },
80
+ { id: 9, name: 'Ikura', category_id: 8, unit_price: 31, units_in_stock: 31, discontinued: false },
81
+ { id: 10, name: 'Queso Cabrales', category_id: 4, unit_price: 21, units_in_stock: 22, discontinued: false },
82
+ { id: 11, name: 'Konbu', category_id: 8, unit_price: 6, units_in_stock: 24, discontinued: false },
83
+ { id: 12, name: 'Tofu', category_id: 7, unit_price: 23.25, units_in_stock: 35, discontinued: false },
84
+ { id: 13, name: 'Genen Shouyu', category_id: 2, unit_price: 15.5, units_in_stock: 39, discontinued: false },
85
+ { id: 14, name: 'Pavlova', category_id: 3, unit_price: 17.45, units_in_stock: 29, discontinued: false },
86
+ { id: 15, name: 'Alice Mutton', category_id: 6, unit_price: 39, units_in_stock: 0, discontinued: true },
87
+ { id: 16, name: 'Carnarvon Tigers', category_id: 8, unit_price: 62.5, units_in_stock: 42, discontinued: false },
88
+ ]
89
+
90
+ const CUSTOMERS: Row[] = [
91
+ { id: 1, company: 'Alfreds Futterkiste', contact: 'Maria Anders', city: 'Berlin', country: 'Germany' },
92
+ { id: 2, company: 'Ana Trujillo Emparedados', contact: 'Ana Trujillo', city: 'México D.F.', country: 'Mexico' },
93
+ { id: 3, company: 'Antonio Moreno Taquería', contact: 'Antonio Moreno', city: 'México D.F.', country: 'Mexico' },
94
+ { id: 4, company: 'Around the Horn', contact: 'Thomas Hardy', city: 'London', country: 'UK' },
95
+ { id: 5, company: 'Berglunds snabbköp', contact: 'Christina Berglund', city: 'Luleå', country: 'Sweden' },
96
+ { id: 6, company: 'Blauer See Delikatessen', contact: 'Hanna Moos', city: 'Mannheim', country: 'Germany' },
97
+ { id: 7, company: 'Bólido Comidas preparadas', contact: 'Martín Sommer', city: 'Madrid', country: 'Spain' },
98
+ { id: 8, company: "Bon app'", contact: 'Laurence Lebihan', city: 'Marseille', country: 'France' },
99
+ { id: 9, company: 'Bottom-Dollar Markets', contact: 'Elizabeth Lincoln', city: 'Tsawassen', country: 'Canada' },
100
+ { id: 10, company: "B's Beverages", contact: 'Victoria Ashworth', city: 'London', country: 'UK' },
101
+ ]
102
+
103
+ // ---- Hosted Supabase sample (see docs/enterprise/studio/supabase-sample.md) --
104
+ // Read-only anon RLS, so the publishable key is safe to embed.
105
+ const SUPABASE_URL = 'https://rbnnlzgtfzsylllniozo.supabase.co'
106
+ const SUPABASE_KEY = 'sb_publishable_SiT7CxGl4Z_Du1opTYbxVA_VQWumPUN'
107
+
108
+ /** Assemble a starter project from entities + screens + explicit per-entity sources. */
109
+ function assemble(opts: {
110
+ title: string
111
+ brand: string
112
+ accent: string
113
+ mode?: 'light' | 'dark'
114
+ preset?: string
115
+ navStyle?: ShellStyle
116
+ entities: EntitySchema[]
117
+ screens: Screen[]
118
+ sources: Record<string, EntityDataSource>
119
+ dataSource: StudioProject['dataSource']
120
+ }): StudioProject {
121
+ return sanitizeProject({
122
+ title: opts.title,
123
+ entities: opts.entities,
124
+ screens: opts.screens,
125
+ dataSource: opts.dataSource,
126
+ dataSources: opts.sources,
127
+ theme: {
128
+ accent: opts.accent,
129
+ ...(opts.mode ? { mode: opts.mode } : {}),
130
+ ...(opts.preset ? { preset: opts.preset } : {}),
131
+ shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: '', navPosition: 'left' },
132
+ },
133
+ })
134
+ }
135
+
136
+ // ---- Starters ---------------------------------------------------------------
137
+
138
+ /** Northwind on embedded Postgres (PGlite) - real SQL + CRUD, zero backend. */
139
+ const northwindPglite: SampleApp = {
140
+ id: 'live-northwind-pglite',
141
+ name: 'Northwind · Local Postgres',
142
+ description: 'The Northwind sample on a real in-browser Postgres (PGlite). Full CRUD, no server, no keys.',
143
+ emoji: '🐘',
144
+ accent: '#2f6f4f',
145
+ build: () =>
146
+ assemble({
147
+ title: 'Northwind (Local Postgres)',
148
+ brand: 'Northwind',
149
+ accent: '#2f6f4f',
150
+ preset: 'material',
151
+ dataSource: 'pglite',
152
+ entities: [products, customers],
153
+ screens: [
154
+ dashScreen(products, { id: 'overview', title: 'Overview', order: 0 }, [
155
+ { kpi: 'Products', reduce: 'count', span: 1 },
156
+ { kpi: 'Avg price', measure: 'unit_price', reduce: 'avg', format: 'currency', span: 1 },
157
+ { kpi: 'In stock', measure: 'units_in_stock', reduce: 'sum', span: 1 },
158
+ { kpi: 'Priciest', measure: 'unit_price', reduce: 'max', format: 'currency', span: 1 },
159
+ { chart: 'category_id', measure: 'unit_price', reduce: 'sum', type: 'bar', span: 2 },
160
+ { chart: 'discontinued', reduce: 'count', type: 'pie', span: 1 },
161
+ { grid: true, rowLink: { screen: 'product-detail', sourceField: 'id', targetField: 'id' }, span: 3 },
162
+ ]),
163
+ detailScreen(products, { id: 'product-detail', title: 'Product', order: 1 }, {
164
+ titleField: 'name', metricFields: ['unit_price', 'units_in_stock'],
165
+ sections: [{ label: 'Catalog', fields: ['category_id', 'discontinued'] }],
166
+ }),
167
+ listScreen(customers, { id: 'customers', title: 'Customers', order: 2 }, { grid: { rowLink: { screen: 'customer-detail', sourceField: 'id', targetField: 'id' } } }),
168
+ detailScreen(customers, { id: 'customer-detail', title: 'Customer', order: 3 }, {
169
+ titleField: 'company', subtitleField: 'contact',
170
+ sections: [{ label: 'Location', fields: ['city', 'country'] }],
171
+ }),
172
+ ],
173
+ sources: {
174
+ products: { kind: 'pglite', table: 'products', seed: PRODUCTS },
175
+ customers: { kind: 'pglite', table: 'customers', seed: CUSTOMERS },
176
+ },
177
+ }),
178
+ }
179
+
180
+ /** Northwind on a hosted Supabase project - live PostgREST, incl. the join view. */
181
+ const northwindSupabase: SampleApp = {
182
+ id: 'live-northwind-supabase',
183
+ name: 'Northwind · Supabase',
184
+ description: 'A hosted, read-only Northwind on Supabase. Browse the five-table order_lines join view live over PostgREST.',
185
+ emoji: '⚡',
186
+ accent: '#3ecf8e',
187
+ build: () =>
188
+ assemble({
189
+ title: 'Northwind (Supabase)',
190
+ brand: 'Northwind',
191
+ accent: '#3ecf8e',
192
+ mode: 'dark',
193
+ preset: 'vercel',
194
+ dataSource: 'supabase',
195
+ entities: [orderLines, products, customers],
196
+ screens: [
197
+ // Sales dashboard over the join view: revenue KPIs + breakdowns by category /
198
+ // country / product, a faceted filter, and the raw order-lines grid.
199
+ dashScreen(orderLines, { id: 'sales', title: 'Sales', order: 0 }, [
200
+ { kpi: 'Line items', reduce: 'count', span: 1 },
201
+ { kpi: 'Revenue', measure: 'line_total', reduce: 'sum', format: 'currency', span: 1 },
202
+ { kpi: 'Avg line', measure: 'line_total', reduce: 'avg', format: 'currency', span: 1 },
203
+ { kpi: 'Units sold', measure: 'quantity', reduce: 'sum', span: 1 },
204
+ { chart: 'category', measure: 'line_total', reduce: 'sum', type: 'bar', span: 2 },
205
+ { chart: 'ship_country', measure: 'line_total', reduce: 'sum', type: 'pie', span: 1 },
206
+ { chart: 'product', measure: 'line_total', reduce: 'sum', type: 'bar', span: 3 },
207
+ { filter: ['category', 'ship_country'], span: 3 },
208
+ { grid: true, span: 3 },
209
+ ]),
210
+ listScreen(products, { id: 'products', title: 'Products', order: 1 }, { grid: { rowLink: { screen: 'product-360', sourceField: 'id', targetField: 'id' } } }),
211
+ // Product 360: unit price / stock tiles + a timeline of the order lines for
212
+ // this product. The view has no FK ids, so the timeline matches by name.
213
+ detailScreen(products, { id: 'product-360', title: 'Product', order: 2 }, {
214
+ titleField: 'name', metricFields: ['unit_price', 'units_in_stock'],
215
+ sections: [{ label: 'Catalog', fields: ['category_id', 'discontinued'] }],
216
+ related: [{ entity: 'order_lines', foreignKey: 'product', parentField: 'name', label: 'Orders', titleField: 'customer', subtitleField: 'line_total', dateField: 'order_date' }],
217
+ }),
218
+ listScreen(customers, { id: 'customers', title: 'Customers', order: 3 }, { grid: { rowLink: { screen: 'customer-360', sourceField: 'id', targetField: 'id' } } }),
219
+ // Customer 360: an order-history timeline matched by company name.
220
+ detailScreen(customers, { id: 'customer-360', title: 'Customer', order: 4 }, {
221
+ titleField: 'company', subtitleField: 'contact',
222
+ sections: [{ label: 'Location', fields: ['city', 'country'] }],
223
+ related: [{ entity: 'order_lines', foreignKey: 'customer', parentField: 'company', label: 'Orders', titleField: 'product', subtitleField: 'line_total', dateField: 'order_date' }],
224
+ }),
225
+ ],
226
+ sources: {
227
+ order_lines: { kind: 'supabase', table: 'order_lines', url: SUPABASE_URL, key: SUPABASE_KEY },
228
+ products: { kind: 'supabase', table: 'products', url: SUPABASE_URL, key: SUPABASE_KEY },
229
+ customers: { kind: 'supabase', table: 'customers', url: SUPABASE_URL, key: SUPABASE_KEY },
230
+ },
231
+ }),
232
+ }
233
+
234
+ /** A live public REST feed (DummyJSON products) - no backend, no keys. */
235
+ const dummyjsonRest: SampleApp = {
236
+ id: 'live-dummyjson-rest',
237
+ name: 'Products · Live REST',
238
+ description: 'A live product feed from the public DummyJSON API over createRestDataSource. No backend, no keys.',
239
+ emoji: '🛰️',
240
+ accent: '#6d5efc',
241
+ build: () => {
242
+ const restProduct: EntitySchema = {
243
+ name: 'products',
244
+ label: 'Product',
245
+ idField: 'id',
246
+ fields: [
247
+ { field: 'id', type: 'number', primaryKey: true, readonly: true },
248
+ { field: 'title', type: 'text', label: 'Product' },
249
+ { field: 'brand', type: 'text', label: 'Brand' },
250
+ { field: 'category', type: 'text', label: 'Category' },
251
+ { field: 'price', type: 'number', label: 'Price ($)' },
252
+ { field: 'rating', type: 'number', label: 'Rating' },
253
+ { field: 'stock', type: 'number', label: 'Stock' },
254
+ ],
255
+ }
256
+ return assemble({
257
+ title: 'Live Products (REST)',
258
+ brand: 'Live Feed',
259
+ accent: '#6d5efc',
260
+ preset: 'shadcn',
261
+ dataSource: 'rest',
262
+ entities: [restProduct],
263
+ // A page size that covers the whole catalogue: the grid asks for one page,
264
+ // so it reads the entire live feed in a single request. (DummyJSON pages by
265
+ // `skip`, not `offset`, so deep server-paging would need a REST adapter -
266
+ // see docs/enterprise/studio/rest-api.md.)
267
+ screens: [
268
+ // Catalog dashboard: price / rating / stock KPIs + category & brand breakdowns
269
+ // over the live feed, with the grid drilling into a product page.
270
+ dashScreen(restProduct, { id: 'catalog', title: 'Catalog', order: 0 }, [
271
+ { kpi: 'Products', reduce: 'count', span: 1 },
272
+ { kpi: 'Avg price', measure: 'price', reduce: 'avg', format: 'currency', span: 1 },
273
+ { kpi: 'Avg rating', measure: 'rating', reduce: 'avg', span: 1 },
274
+ { kpi: 'In stock', measure: 'stock', reduce: 'sum', span: 1 },
275
+ { chart: 'category', reduce: 'count', type: 'bar', span: 2 },
276
+ { chart: 'brand', measure: 'price', reduce: 'avg', type: 'bar', span: 1 },
277
+ { filter: ['category', 'brand'], span: 3 },
278
+ { grid: true, pageSize: 200, rowLink: { screen: 'product-detail', sourceField: 'id', targetField: 'id' }, span: 3 },
279
+ ]),
280
+ detailScreen(restProduct, { id: 'product-detail', title: 'Product', order: 1 }, {
281
+ titleField: 'title', subtitleField: 'brand', metricFields: ['price', 'rating', 'stock'],
282
+ sections: [{ label: 'Details', fields: ['category', 'brand'] }],
283
+ }),
284
+ ],
285
+ sources: {
286
+ // Rows + total read from DummyJSON's { products, total } envelope. `limit=0`
287
+ // asks DummyJSON for the whole catalogue so the dashboard aggregates it all.
288
+ products: {
289
+ kind: 'rest',
290
+ baseUrl: 'https://dummyjson.com',
291
+ path: 'products',
292
+ method: 'GET',
293
+ params: [{ name: 'limit', location: 'query', type: 'number', value: '0' }],
294
+ rowsPath: 'products',
295
+ totalPath: 'total',
296
+ },
297
+ },
298
+ })
299
+ },
300
+ }
301
+
302
+ /** Starters wired to a real data source, in gallery order. */
303
+ export const liveDataSamples: SampleApp[] = [northwindPglite, northwindSupabase, dummyjsonRest]
304
+
305
+ /** Look up a live-data starter by id. */
306
+ export function getLiveDataSample(id: string): SampleApp | undefined {
307
+ return liveDataSamples.find((s) => s.id === id)
308
+ }