@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
package/src/smart-shim.ts CHANGED
@@ -1,105 +1,107 @@
1
- // Minimal globalThis.Smart shim so the vendored smart.export.js IIFE can
2
- // register Smart.Utilities.DataExporter without dragging in the rest of the
3
- // Smart UI runtime. DataExporter calls into Smart.Utilities.{Assign, Core.toDash}
4
- // at registration; the other namespaces (DateTime, NumberRenderer, Draw,
5
- // BigNumber, Complex) are guarded by `if (!Smart.Utilities.X) return e;` paths
6
- // inside the exporter and degrade gracefully when missing.
7
- //
8
- // We export a function instead of relying on import-side-effects so callers
9
- // can deterministically install the shim before the smart.export.js module
10
- // runs its IIFE.
11
-
12
- type SmartGlobal = {
13
- Utilities: {
14
- Assign(name: string, cls: unknown): void
15
- Core: {
16
- toDash(input: string): string
17
- }
18
- DataExporter?: new (
19
- options: Record<string, unknown>,
20
- groupBy?: ReadonlyArray<string>,
21
- filterBy?: Record<string, unknown>,
22
- conditionalFormatting?: unknown,
23
- ) => SmartDataExporterInstance
24
- [key: string]: unknown
25
- }
26
- }
27
-
28
- /**
29
- * Shape of the image object Smart's xlsx writer expects from
30
- * `addImageToCell(...).image`. `base64` may be a full data URL; the
31
- * exporter strips the prefix.
32
- */
33
- export type SmartImageDescriptor = {
34
- id: string
35
- base64: string
36
- imageType: string
37
- width: number
38
- height: number
39
- }
40
-
41
- export type SmartDataExporterInstance = {
42
- /**
43
- * Smart looks for these on the INSTANCE (not the constructor opts) at
44
- * exportData() time. The wrapper sets them after `new DataExporter(...)`.
45
- */
46
- headerContent?: Array<{
47
- cells: Record<string, unknown>
48
- style?: Record<string, unknown>
49
- }>
50
- footerContent?: Array<{
51
- cells: Record<string, unknown>
52
- style?: Record<string, unknown>
53
- }>
54
- addImageToCell?: (
55
- rowIndex: number,
56
- dataField: string,
57
- value: unknown,
58
- rawValue: unknown,
59
- row: Record<string, unknown>,
60
- arrIndex: number,
61
- ) => { image: SmartImageDescriptor; value?: unknown } | null
62
- spreadsheets?: ReadonlyArray<{
63
- label: string
64
- dataSource: ReadonlyArray<Record<string, unknown>>
65
- columns: ReadonlyArray<{ dataField: string; label: string }>
66
- dataFields: ReadonlyArray<string>
67
- style?: unknown
68
- }>
69
- exportData(
70
- data: ReadonlyArray<Record<string, unknown>>,
71
- format: string,
72
- filename?: string,
73
- callback?: (result: unknown) => void,
74
- ): unknown
75
- }
76
-
77
- declare global {
78
- interface Window {
79
- Smart?: SmartGlobal
80
- }
81
- }
82
-
83
- let installed = false
84
-
85
- export function installSmartShim(): void {
86
- if (installed) return
87
- if (typeof window === 'undefined') {
88
- throw new Error('@svgrid/enterprise: installSmartShim() requires a browser environment')
89
- }
90
- const existing = window.Smart
91
- const shim: SmartGlobal = existing ?? ({ Utilities: {} as SmartGlobal['Utilities'] })
92
- shim.Utilities = shim.Utilities ?? ({} as SmartGlobal['Utilities'])
93
- if (typeof shim.Utilities.Assign !== 'function') {
94
- shim.Utilities.Assign = (name: string, cls: unknown) => {
95
- ;(shim.Utilities as Record<string, unknown>)[name] = cls
96
- }
97
- }
98
- if (!shim.Utilities.Core || typeof shim.Utilities.Core.toDash !== 'function') {
99
- shim.Utilities.Core = {
100
- toDash: (s: string) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()),
101
- }
102
- }
103
- window.Smart = shim
104
- installed = true
105
- }
1
+ // Minimal globalThis.Smart shim so the vendored smart.export.js IIFE can
2
+ // register Smart.Utilities.DataExporter without dragging in the rest of the
3
+ // Smart UI runtime. DataExporter calls into Smart.Utilities.{Assign, Core.toDash}
4
+ // at registration; the other namespaces (DateTime, NumberRenderer, Draw,
5
+ // BigNumber, Complex) are guarded by `if (!Smart.Utilities.X) return e;` paths
6
+ // inside the exporter and degrade gracefully when missing.
7
+ //
8
+ // We export a function instead of relying on import-side-effects so callers
9
+ // can deterministically install the shim before the smart.export.js module
10
+ // runs its IIFE.
11
+
12
+ type SmartGlobal = {
13
+ Utilities: {
14
+ Assign(name: string, cls: unknown): void
15
+ Core: {
16
+ toDash(input: string): string
17
+ }
18
+ DataExporter?: new (
19
+ options: Record<string, unknown>,
20
+ groupBy?: ReadonlyArray<string>,
21
+ filterBy?: Record<string, unknown>,
22
+ conditionalFormatting?: unknown,
23
+ ) => SmartDataExporterInstance
24
+ [key: string]: unknown
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Shape of the image object Smart's xlsx writer expects from
30
+ * `addImageToCell(...).image`. `base64` may be a full data URL; the
31
+ * exporter strips the prefix.
32
+ */
33
+ export type SmartImageDescriptor = {
34
+ id: string
35
+ base64: string
36
+ imageType: string
37
+ width: number
38
+ height: number
39
+ }
40
+
41
+ export type SmartDataExporterInstance = {
42
+ /**
43
+ * Smart looks for these on the INSTANCE (not the constructor opts) at
44
+ * exportData() time. The wrapper sets them after `new DataExporter(...)`.
45
+ */
46
+ headerContent?: Array<{
47
+ cells: Record<string, unknown>
48
+ style?: Record<string, unknown>
49
+ }>
50
+ footerContent?: Array<{
51
+ cells: Record<string, unknown>
52
+ style?: Record<string, unknown>
53
+ }>
54
+ addImageToCell?: (
55
+ rowIndex: number,
56
+ dataField: string,
57
+ value: unknown,
58
+ rawValue: unknown,
59
+ row: Record<string, unknown>,
60
+ arrIndex: number,
61
+ ) => { image: SmartImageDescriptor; value?: unknown } | null
62
+ spreadsheets?: ReadonlyArray<{
63
+ label: string
64
+ dataSource: ReadonlyArray<Record<string, unknown>>
65
+ /** `format` is an optional Smart/Excel number-format token consumed by
66
+ * the xlsx writer's `getXLSXFormat` to keep a column's cells typed. */
67
+ columns: ReadonlyArray<{ dataField: string; label: string; format?: string; width?: number }>
68
+ dataFields: ReadonlyArray<string>
69
+ style?: unknown
70
+ }>
71
+ exportData(
72
+ data: ReadonlyArray<Record<string, unknown>>,
73
+ format: string,
74
+ filename?: string,
75
+ callback?: (result: unknown) => void,
76
+ ): unknown
77
+ }
78
+
79
+ declare global {
80
+ interface Window {
81
+ Smart?: SmartGlobal
82
+ }
83
+ }
84
+
85
+ let installed = false
86
+
87
+ export function installSmartShim(): void {
88
+ if (installed) return
89
+ if (typeof window === 'undefined') {
90
+ throw new Error('@svgrid/enterprise: installSmartShim() requires a browser environment')
91
+ }
92
+ const existing = window.Smart
93
+ const shim: SmartGlobal = existing ?? ({ Utilities: {} as SmartGlobal['Utilities'] })
94
+ shim.Utilities = shim.Utilities ?? ({} as SmartGlobal['Utilities'])
95
+ if (typeof shim.Utilities.Assign !== 'function') {
96
+ shim.Utilities.Assign = (name: string, cls: unknown) => {
97
+ ;(shim.Utilities as Record<string, unknown>)[name] = cls
98
+ }
99
+ }
100
+ if (!shim.Utilities.Core || typeof shim.Utilities.Core.toDash !== 'function') {
101
+ shim.Utilities.Core = {
102
+ toDash: (s: string) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()),
103
+ }
104
+ }
105
+ window.Smart = shim
106
+ installed = true
107
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { EntitySchema } from '../schema'
3
+ import { aggregateRows, chartFieldsFromSchema } from './aggregate'
4
+
5
+ const rows = [
6
+ { id: 1, tier: 'pro', mrr: 100, active: true },
7
+ { id: 2, tier: 'pro', mrr: 300, active: true },
8
+ { id: 3, tier: 'free', mrr: 0, active: false },
9
+ { id: 4, tier: 'enterprise', mrr: 1000, active: true },
10
+ ]
11
+
12
+ describe('aggregateRows', () => {
13
+ it('sums a measure by dimension, sorted desc', () => {
14
+ expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'sum' })).toEqual([
15
+ { category: 'enterprise', value: 1000 },
16
+ { category: 'pro', value: 400 },
17
+ { category: 'free', value: 0 },
18
+ ])
19
+ })
20
+
21
+ it('counts rows per dimension (measure ignored); ties keep insertion order', () => {
22
+ expect(aggregateRows(rows, { dimension: 'tier', reduce: 'count' })).toEqual([
23
+ { category: 'pro', value: 2 },
24
+ { category: 'free', value: 1 },
25
+ { category: 'enterprise', value: 1 },
26
+ ])
27
+ })
28
+
29
+ it('averages, and handles min / max', () => {
30
+ expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'avg' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 200 })
31
+ expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'max' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 300 })
32
+ expect(aggregateRows(rows, { dimension: 'tier', measure: 'mrr', reduce: 'min' }).find((b) => b.category === 'pro')).toEqual({ category: 'pro', value: 100 })
33
+ })
34
+
35
+ it('groups a boolean dimension', () => {
36
+ const out = aggregateRows(rows, { dimension: 'active', reduce: 'count' })
37
+ expect(out).toEqual([{ category: 'true', value: 3 }, { category: 'false', value: 1 }])
38
+ })
39
+
40
+ it('with no dimension, returns a single grand-total bucket (a KPI)', () => {
41
+ expect(aggregateRows(rows, { reduce: 'count' })).toEqual([{ category: '', value: 4 }])
42
+ expect(aggregateRows(rows, { measure: 'mrr', reduce: 'sum' })).toEqual([{ category: '', value: 1400 }])
43
+ expect(aggregateRows(rows, { measure: 'mrr', reduce: 'max' })).toEqual([{ category: '', value: 1000 }])
44
+ })
45
+ })
46
+
47
+ describe('chartFieldsFromSchema', () => {
48
+ const schema: EntitySchema = {
49
+ name: 'customers', idField: 'id',
50
+ fields: [
51
+ { field: 'id', type: 'text', primaryKey: true },
52
+ { field: 'name', type: 'text' },
53
+ { field: 'tier', type: 'enum', options: [] },
54
+ { field: 'mrr', type: 'number' },
55
+ { field: 'seats', type: 'number' },
56
+ { field: 'active', type: 'boolean' },
57
+ ],
58
+ }
59
+
60
+ it('classifies dimensions (enum/boolean/text, non-key) and measures (number)', () => {
61
+ const f = chartFieldsFromSchema(schema)
62
+ expect(f.dimensions).toEqual(['name', 'tier', 'active'])
63
+ expect(f.measures).toEqual(['mrr', 'seats'])
64
+ })
65
+
66
+ it('prefers an enum dimension and the first measure as defaults', () => {
67
+ const f = chartFieldsFromSchema(schema)
68
+ expect(f.defaultDimension).toBe('tier')
69
+ expect(f.defaultMeasure).toBe('mrr')
70
+ })
71
+
72
+ it('falls back to boolean then text when there is no enum', () => {
73
+ const noEnum: EntitySchema = {
74
+ name: 't', idField: 'id',
75
+ fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'label', type: 'text' }, { field: 'on', type: 'boolean' }],
76
+ }
77
+ expect(chartFieldsFromSchema(noEnum).defaultDimension).toBe('on')
78
+ })
79
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Aggregation for charts / dashboards: group rows by a dimension and reduce a
3
+ * measure into `{ category, value }` buckets. `aggregateRows` does it in memory;
4
+ * data sources can implement `getAggregate` to push the same shape down to the
5
+ * database (a `GROUP BY`) so it scales past what a page can hold. `SvSchemaChart`
6
+ * consumes either.
7
+ */
8
+ import type { RowData, ServerFilterModel } from '@svgrid/grid'
9
+ import type { EntitySchema } from '../schema'
10
+
11
+ export type AggregateReduce = 'sum' | 'avg' | 'count' | 'min' | 'max'
12
+
13
+ export type AggregateRequest = {
14
+ /**
15
+ * Field to group by (the chart's category axis). Omit it for a single grand
16
+ * total (a KPI) - the result is one bucket with an empty `category`.
17
+ */
18
+ dimension?: string
19
+ /** Field to reduce. Ignored for `count`. */
20
+ measure?: string
21
+ reduce: AggregateReduce
22
+ /** Optional filter, same shape the grid emits. */
23
+ filterModel?: ServerFilterModel
24
+ }
25
+
26
+ export type AggregateBucket = { category: string; value: number }
27
+
28
+ /** A data source that can aggregate server-side (e.g. a SQL `GROUP BY`). */
29
+ export type AggregateSource = {
30
+ getAggregate(request: AggregateRequest): Promise<AggregateBucket[]>
31
+ }
32
+
33
+ const num = (v: unknown) => {
34
+ const n = Number(v)
35
+ return Number.isFinite(n) ? n : 0
36
+ }
37
+
38
+ /** Group `rows` by `dimension` and reduce `measure`; sorted by value desc. */
39
+ export function aggregateRows<T extends RowData>(
40
+ rows: ReadonlyArray<T>,
41
+ request: AggregateRequest,
42
+ ): AggregateBucket[] {
43
+ const { dimension, measure, reduce } = request
44
+ const sum = new Map<string, number>()
45
+ const count = new Map<string, number>()
46
+ const ext = new Map<string, number>() // running min/max
47
+
48
+ for (const row of rows) {
49
+ // No dimension -> a single grand-total bucket (KPI).
50
+ const category = dimension ? String(row[dimension] ?? '') : ''
51
+ count.set(category, (count.get(category) ?? 0) + 1)
52
+ if (reduce !== 'count') {
53
+ const v = num(measure ? row[measure] : 0)
54
+ sum.set(category, (sum.get(category) ?? 0) + v)
55
+ if (reduce === 'min') ext.set(category, Math.min(ext.get(category) ?? Infinity, v))
56
+ if (reduce === 'max') ext.set(category, Math.max(ext.get(category) ?? -Infinity, v))
57
+ }
58
+ }
59
+
60
+ const buckets: AggregateBucket[] = [...count.keys()].map((category) => {
61
+ const c = count.get(category)!
62
+ let value: number
63
+ if (reduce === 'count') value = c
64
+ else if (reduce === 'avg') value = c ? (sum.get(category) ?? 0) / c : 0
65
+ else if (reduce === 'min' || reduce === 'max') value = ext.get(category) ?? 0
66
+ else value = sum.get(category) ?? 0
67
+ return { category, value }
68
+ })
69
+
70
+ return buckets.sort((a, b) => b.value - a.value)
71
+ }
72
+
73
+ /**
74
+ * Pick chart-able fields from a schema: **dimensions** to group by (enum /
75
+ * boolean / text, non-key) and **measures** to reduce (number). Defaults favor a
76
+ * low-cardinality dimension (enum, then boolean) and the first numeric measure.
77
+ */
78
+ export function chartFieldsFromSchema(schema: EntitySchema): {
79
+ dimensions: string[]
80
+ measures: string[]
81
+ defaultDimension: string | null
82
+ defaultMeasure: string | null
83
+ } {
84
+ const nonKey = schema.fields.filter((f) => !f.primaryKey)
85
+ const dimensions = nonKey
86
+ .filter((f) => f.type === 'enum' || f.type === 'boolean' || f.type === 'text')
87
+ .map((f) => f.field)
88
+ const measures = nonKey.filter((f) => f.type === 'number').map((f) => f.field)
89
+
90
+ const preferred =
91
+ nonKey.find((f) => f.type === 'enum')?.field ??
92
+ nonKey.find((f) => f.type === 'boolean')?.field ??
93
+ dimensions[0] ??
94
+ null
95
+
96
+ return {
97
+ dimensions,
98
+ measures,
99
+ defaultDimension: preferred,
100
+ defaultMeasure: measures[0] ?? null,
101
+ }
102
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSupabaseAuth, type AuthState } from './auth-supabase'
3
+
4
+ /** A supabase-js-like auth client with controllable session + spies. */
5
+ function makeClient(initialSession: { user: { id: string; email?: string } } | null = null) {
6
+ let cb: ((event: string, session: unknown) => void) | undefined
7
+ const unsubscribe = vi.fn()
8
+ type AuthResult = { error: { message: string } | null }
9
+ const ok = async (): Promise<AuthResult> => ({ error: null })
10
+ const client = {
11
+ auth: {
12
+ getSession: vi.fn(async () => ({ data: { session: initialSession } })),
13
+ onAuthStateChange: vi.fn((fn: (e: string, s: unknown) => void) => {
14
+ cb = fn
15
+ return { data: { subscription: { unsubscribe } } }
16
+ }),
17
+ signInWithPassword: vi.fn(ok),
18
+ signUp: vi.fn(ok),
19
+ signOut: vi.fn(ok),
20
+ },
21
+ }
22
+ return { client, unsubscribe, fire: (session: unknown) => cb?.('x', session) }
23
+ }
24
+
25
+ const track = () => {
26
+ const states: AuthState[] = []
27
+ return { states, onChange: (s: AuthState) => states.push(s) }
28
+ }
29
+
30
+ describe('createSupabaseAuth', () => {
31
+ it('resolves the current session on init', async () => {
32
+ const { client } = makeClient({ user: { id: 'u1', email: 'a@b.co' } })
33
+ const { states, onChange } = track()
34
+ createSupabaseAuth({ client, onChange })
35
+ await vi.waitFor(() => expect(states.at(-1)?.user).toEqual({ id: 'u1', email: 'a@b.co' }))
36
+ expect(states.at(-1)?.loading).toBe(false)
37
+ })
38
+
39
+ it('starts in a loading, signed-out state', () => {
40
+ const { client } = makeClient(null)
41
+ const { states, onChange } = track()
42
+ createSupabaseAuth({ client, onChange })
43
+ expect(states[0]).toEqual({ user: null, loading: true, error: null })
44
+ })
45
+
46
+ it('updates when auth state changes (sign in elsewhere)', async () => {
47
+ const { client, fire } = makeClient(null)
48
+ const { states, onChange } = track()
49
+ createSupabaseAuth({ client, onChange })
50
+ await vi.waitFor(() => expect(states.at(-1)?.loading).toBe(false))
51
+ fire({ user: { id: 'u2', email: 'x@y.co' } })
52
+ expect(states.at(-1)?.user).toEqual({ id: 'u2', email: 'x@y.co' })
53
+ })
54
+
55
+ it('signIn calls signInWithPassword and surfaces errors', async () => {
56
+ const { client } = makeClient(null)
57
+ client.auth.signInWithPassword.mockResolvedValueOnce({ error: { message: 'bad creds' } })
58
+ const { states, onChange } = track()
59
+ const auth = createSupabaseAuth({ client, onChange })
60
+ await auth.signIn('a@b.co', 'pw')
61
+ expect(client.auth.signInWithPassword).toHaveBeenCalledWith({ email: 'a@b.co', password: 'pw' })
62
+ expect(states.at(-1)).toMatchObject({ error: 'bad creds', loading: false })
63
+ })
64
+
65
+ it('signUp and signOut call through', async () => {
66
+ const { client } = makeClient(null)
67
+ const auth = createSupabaseAuth({ client, onChange: () => {} })
68
+ await auth.signUp('a@b.co', 'pw')
69
+ await auth.signOut()
70
+ expect(client.auth.signUp).toHaveBeenCalledWith({ email: 'a@b.co', password: 'pw' })
71
+ expect(client.auth.signOut).toHaveBeenCalled()
72
+ })
73
+
74
+ it('dispose unsubscribes from auth changes', () => {
75
+ const { client, unsubscribe } = makeClient(null)
76
+ const auth = createSupabaseAuth({ client, onChange: () => {} })
77
+ auth.dispose()
78
+ expect(unsubscribe).toHaveBeenCalled()
79
+ })
80
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * createSupabaseAuth - a tiny reactive controller over Supabase Auth, so a
3
+ * Studio screen can require a signed-in user. You bring the `supabase-js` client
4
+ * (BYO, no dependency); this tracks the session and exposes sign in / up / out.
5
+ * Pair it with `SvAuthGate` to gate the UI, and with Row-Level Security to scope
6
+ * data per user - auth establishes *who*, RLS enforces *what they can see*.
7
+ *
8
+ * const auth = createSupabaseAuth({ client, onChange: (s) => (authState = s) })
9
+ * await auth.signIn(email, password)
10
+ * // ... auth.signOut(); auth.dispose() on unmount
11
+ */
12
+ import { nudgeEnterprise } from '../license'
13
+
14
+ export type AuthUser = { id: string; email?: string }
15
+
16
+ export type AuthState = {
17
+ /** The signed-in user, or null. */
18
+ user: AuthUser | null
19
+ /** True during the initial session check or an in-flight auth call. */
20
+ loading: boolean
21
+ /** Last auth error message, or null. */
22
+ error: string | null
23
+ }
24
+
25
+ type Session = { user: { id: string; email?: string } } | null
26
+
27
+ /** The slice of a `supabase-js` client's `auth` this needs. */
28
+ export type SupabaseAuthClientLike = {
29
+ auth: {
30
+ getSession(): Promise<{ data: { session: Session } }>
31
+ onAuthStateChange(cb: (event: string, session: Session) => void): {
32
+ data: { subscription: { unsubscribe(): void } }
33
+ }
34
+ signInWithPassword(c: { email: string; password: string }): Promise<{ error: { message: string } | null }>
35
+ signUp(c: { email: string; password: string }): Promise<{ error: { message: string } | null }>
36
+ signOut(): Promise<{ error: { message: string } | null }>
37
+ }
38
+ }
39
+
40
+ export type SupabaseAuthConfig = {
41
+ client: SupabaseAuthClientLike
42
+ onChange: (state: AuthState) => void
43
+ }
44
+
45
+ export type SupabaseAuthController = {
46
+ signIn(email: string, password: string): Promise<void>
47
+ signUp(email: string, password: string): Promise<void>
48
+ signOut(): Promise<void>
49
+ getState(): AuthState
50
+ dispose(): void
51
+ }
52
+
53
+ const userOf = (session: Session): AuthUser | null =>
54
+ session?.user ? { id: session.user.id, email: session.user.email } : null
55
+
56
+ export function createSupabaseAuth(config: SupabaseAuthConfig): SupabaseAuthController {
57
+ nudgeEnterprise('Studio') // soft-gate; never blocks
58
+ let state: AuthState = { user: null, loading: true, error: null }
59
+ const emit = () => config.onChange({ ...state })
60
+ const set = (patch: Partial<AuthState>) => {
61
+ state = { ...state, ...patch }
62
+ emit()
63
+ }
64
+
65
+ emit() // initial loading state
66
+
67
+ // Resolve the current session, then keep it in sync.
68
+ config.client.auth.getSession().then(({ data }) => set({ user: userOf(data.session), loading: false }))
69
+ const sub = config.client.auth.onAuthStateChange((_event, session) =>
70
+ set({ user: userOf(session), loading: false }),
71
+ )
72
+
73
+ const run = async (op: () => Promise<{ error: { message: string } | null }>) => {
74
+ set({ loading: true, error: null })
75
+ const { error } = await op()
76
+ // On success the user arrives via onAuthStateChange; just clear loading.
77
+ set(error ? { loading: false, error: error.message } : { loading: false })
78
+ }
79
+
80
+ return {
81
+ signIn: (email, password) => run(() => config.client.auth.signInWithPassword({ email, password })),
82
+ signUp: (email, password) => run(() => config.client.auth.signUp({ email, password })),
83
+ signOut: () => run(() => config.client.auth.signOut()),
84
+ getState: () => ({ ...state }),
85
+ dispose: () => sub.data.subscription.unsubscribe(),
86
+ }
87
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { kpiSeries, sparklinePoints, seriesDelta, formatKpiValue } from './dashboard'
3
+
4
+ describe('kpiSeries', () => {
5
+ const rows = [
6
+ { stage: '2026-01', value: 10 },
7
+ { stage: '2026-01', value: 5 },
8
+ { stage: '2026-03', value: 20 },
9
+ { stage: '2026-02', value: 8 },
10
+ { stage: '', value: 99 }, // empty bucket key is skipped
11
+ ]
12
+ it('buckets by trendField (sorted) and reduces the measure', () => {
13
+ expect(kpiSeries(rows, { trendField: 'stage', measure: 'value', reduce: 'sum' })).toEqual([15, 8, 20])
14
+ })
15
+ it('counts rows per bucket when reduce is count', () => {
16
+ expect(kpiSeries(rows, { trendField: 'stage', reduce: 'count' })).toEqual([2, 1, 1])
17
+ })
18
+ })
19
+
20
+ describe('sparklinePoints', () => {
21
+ it('maps values into a w x h box with the max at the top', () => {
22
+ const pts = sparklinePoints([0, 10], 100, 20, 0).split(' ')
23
+ expect(pts).toHaveLength(2)
24
+ expect(pts[0]).toBe('0.0,20.0') // min -> bottom
25
+ expect(pts[1]).toBe('100.0,0.0') // max -> top, last x at width
26
+ })
27
+ it('is empty for no data', () => {
28
+ expect(sparklinePoints([])).toBe('')
29
+ })
30
+ })
31
+
32
+ describe('seriesDelta', () => {
33
+ it('is the percent change from first non-zero to last', () => {
34
+ expect(seriesDelta([100, 150])).toBe(50)
35
+ expect(seriesDelta([0, 100, 50])).toBe(-50) // first non-zero is 100, last is 50
36
+ })
37
+ it('is null when it cannot be computed', () => {
38
+ expect(seriesDelta([5])).toBeNull()
39
+ expect(seriesDelta([0, 0])).toBeNull()
40
+ })
41
+ })
42
+
43
+ describe('formatKpiValue', () => {
44
+ it('formats currency / percent / compact / number', () => {
45
+ expect(formatKpiValue(1234.5, 'currency')).toBe('$1,235')
46
+ expect(formatKpiValue(42.5, 'percent')).toBe('42.5%')
47
+ expect(formatKpiValue(12500, 'compact')).toBe('12.5K')
48
+ expect(formatKpiValue(1234.5, 'number')).toBe('1,234.5')
49
+ })
50
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { EntitySchema } from '../schema'
3
+ import { dashboardFromSchema, reduceValue } from './dashboard'
4
+
5
+ const rows = [
6
+ { id: '1', tier: 'pro', mrr: 100 },
7
+ { id: '2', tier: 'free', mrr: 0 },
8
+ { id: '3', tier: 'pro', mrr: 300 },
9
+ ]
10
+
11
+ describe('reduceValue', () => {
12
+ it('counts rows regardless of measure', () => {
13
+ expect(reduceValue(rows, { reduce: 'count' })).toBe(3)
14
+ expect(reduceValue(rows, { measure: 'mrr', reduce: 'count' })).toBe(3)
15
+ })
16
+ it('sums / averages / min / max a measure', () => {
17
+ expect(reduceValue(rows, { measure: 'mrr', reduce: 'sum' })).toBe(400)
18
+ expect(reduceValue(rows, { measure: 'mrr', reduce: 'avg' })).toBeCloseTo(133.33, 1)
19
+ expect(reduceValue(rows, { measure: 'mrr', reduce: 'min' })).toBe(0)
20
+ expect(reduceValue(rows, { measure: 'mrr', reduce: 'max' })).toBe(300)
21
+ })
22
+ it('is 0 for an empty set', () => {
23
+ expect(reduceValue([], { measure: 'mrr', reduce: 'sum' })).toBe(0)
24
+ })
25
+ })
26
+
27
+ describe('dashboardFromSchema', () => {
28
+ const schema: EntitySchema = {
29
+ name: 'customers',
30
+ label: 'Customer',
31
+ fields: [
32
+ { field: 'id', type: 'text', primaryKey: true },
33
+ { field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
34
+ { field: 'mrr', type: 'number' },
35
+ ],
36
+ }
37
+
38
+ it('proposes count + measure KPIs and a chart on the default dimension', () => {
39
+ const { widgets } = dashboardFromSchema(schema)
40
+ const kpis = widgets.filter((w) => w.kind === 'kpi')
41
+ const charts = widgets.filter((w) => w.kind === 'chart')
42
+ expect(kpis.some((w) => w.reduce === 'count')).toBe(true)
43
+ expect(kpis.some((w) => w.kind === 'kpi' && w.measure === 'mrr' && w.reduce === 'sum')).toBe(true)
44
+ expect(charts).toHaveLength(1)
45
+ expect(charts[0]).toMatchObject({ kind: 'chart', dimension: 'tier', measure: 'mrr' })
46
+ })
47
+
48
+ it('degrades to a count-only dashboard when there is no measure', () => {
49
+ const noMeasure: EntitySchema = {
50
+ name: 'tags',
51
+ fields: [
52
+ { field: 'id', type: 'text', primaryKey: true },
53
+ { field: 'kind', type: 'enum', options: [{ value: 'a', label: 'A' }] },
54
+ ],
55
+ }
56
+ const { widgets } = dashboardFromSchema(noMeasure)
57
+ expect(widgets.filter((w) => w.kind === 'kpi')).toHaveLength(1)
58
+ const chart = widgets.find((w) => w.kind === 'chart')
59
+ expect(chart).toMatchObject({ reduce: 'count' })
60
+ })
61
+ })