@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
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ServerDataSource, ServerRequest } from '@svgrid/grid'
3
+ import type { EntitySchema } from '../schema'
4
+ import { withEntityRules } from './with-entity-rules'
5
+
6
+ type Order = { id: string; qty: number; price: number; total: number; createdAt?: string }
7
+
8
+ const schema: EntitySchema<Order> = {
9
+ name: 'orders',
10
+ idField: 'id',
11
+ fields: [
12
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
13
+ { field: 'qty', type: 'number', required: true },
14
+ { field: 'price', type: 'number', required: true },
15
+ { field: 'total', type: 'number', computed: (r) => Number(r.qty) * Number(r.price) },
16
+ ],
17
+ }
18
+
19
+ const req: ServerRequest = { startRow: 0, endRow: 10, pageIndex: 0, pageSize: 10, sortModel: [], filterModel: {} }
20
+
21
+ /** A tiny in-memory source we can wrap. */
22
+ function makeSource(seed: Order[] = []): ServerDataSource<Order> {
23
+ let rows = [...seed]
24
+ return {
25
+ async getRows() {
26
+ return { rows: [...rows], rowCount: rows.length }
27
+ },
28
+ async createRow(input) {
29
+ const row = { id: 'x', qty: 0, price: 0, total: 0, ...input } as Order
30
+ rows = [...rows, row]
31
+ return row
32
+ },
33
+ async updateRow(id, patch) {
34
+ const row = { ...rows.find((r) => r.id === id)!, ...patch }
35
+ rows = rows.map((r) => (r.id === id ? row : r))
36
+ return row
37
+ },
38
+ async deleteRow(id) {
39
+ rows = rows.filter((r) => r.id !== id)
40
+ },
41
+ }
42
+ }
43
+
44
+ describe('withEntityRules - computed materialization', () => {
45
+ it('materializes computed fields on getRows', async () => {
46
+ const source = withEntityRules(makeSource([{ id: '1', qty: 3, price: 10, total: 0 }]), schema)
47
+ const { rows } = await source.getRows(req)
48
+ expect(rows[0]!.total).toBe(30)
49
+ })
50
+
51
+ it('materializes computed fields on the created/updated row', async () => {
52
+ const source = withEntityRules(makeSource(), schema)
53
+ const created = await source.createRow!({ id: '1', qty: 2, price: 5 })
54
+ expect(created.total).toBe(10)
55
+ const updated = await source.updateRow!('1', { qty: 4, price: 5 })
56
+ expect(updated.total).toBe(20)
57
+ })
58
+ })
59
+
60
+ describe('withEntityRules - hooks', () => {
61
+ it('runs beforeCreate to transform the payload and afterCreate as a side effect', async () => {
62
+ const afterCreate = vi.fn()
63
+ const withHooks: EntitySchema<Order> = {
64
+ ...schema,
65
+ hooks: { beforeCreate: (v) => ({ ...v, createdAt: '2026-01-01' }), afterCreate },
66
+ }
67
+ const source = withEntityRules(makeSource(), withHooks)
68
+ const row = await source.createRow!({ id: '1', qty: 1, price: 1 })
69
+ expect(row.createdAt).toBe('2026-01-01')
70
+ expect(afterCreate).toHaveBeenCalledWith(expect.objectContaining({ id: '1' }))
71
+ })
72
+
73
+ it('rejects a write when cross-field validate fails', async () => {
74
+ const withHooks: EntitySchema<Order> = {
75
+ ...schema,
76
+ hooks: { validate: (v) => (Number(v.qty) <= 0 ? { qty: 'Quantity must be positive' } : null) },
77
+ }
78
+ const source = withEntityRules(makeSource(), withHooks)
79
+ await expect(source.createRow!({ id: '1', qty: 0, price: 5 })).rejects.toThrow(/Quantity must be positive/)
80
+ })
81
+
82
+ it('vetoes a delete when beforeDelete throws', async () => {
83
+ const withHooks: EntitySchema<Order> = {
84
+ ...schema,
85
+ hooks: { beforeDelete: () => { throw new Error('locked') } },
86
+ }
87
+ const source = withEntityRules(makeSource([{ id: '1', qty: 1, price: 1, total: 1 }]), withHooks)
88
+ await expect(source.deleteRow!('1')).rejects.toThrow(/locked/)
89
+ })
90
+
91
+ it('leaves optional write methods absent when the source lacks them', () => {
92
+ const readOnly: ServerDataSource<Order> = { getRows: async () => ({ rows: [], rowCount: 0 }) }
93
+ const wrapped = withEntityRules(readOnly, schema)
94
+ expect(wrapped.createRow).toBeUndefined()
95
+ expect(wrapped.deleteRow).toBeUndefined()
96
+ })
97
+
98
+ it('passes through extra source capabilities (getAggregate)', () => {
99
+ const getAggregate = vi.fn()
100
+ const source = Object.assign(makeSource(), { getAggregate })
101
+ const wrapped = withEntityRules(source, schema)
102
+ expect((wrapped as typeof source).getAggregate).toBe(getAggregate)
103
+ })
104
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * withEntityRules - apply an `EntitySchema`'s computed fields and lifecycle
3
+ * hooks to any `ServerDataSource`, at the mutation boundary. It's a transparent
4
+ * wrapper: pass the result to `createServerDataSource` (or straight to the grid)
5
+ * and every read row gets its computed fields materialized, while writes run the
6
+ * schema's before/after hooks and cross-field validation.
7
+ *
8
+ * const source = withEntityRules(createSqlDataSource(...), orderSchema)
9
+ *
10
+ * - `getRows` -> each row through `applyComputed` (grid can sort/filter the
11
+ * computed value within the fetched page).
12
+ * - `createRow` -> `hooks.validate` + `hooks.beforeCreate` (may transform the
13
+ * payload or throw), then `applyComputed` on the returned row,
14
+ * then `hooks.afterCreate`.
15
+ * - `updateRow` -> the same with `beforeUpdate` / `afterUpdate`.
16
+ * - `deleteRow` -> `hooks.beforeDelete` (throw to veto) then `hooks.afterDelete`.
17
+ *
18
+ * Optional write methods stay optional, and any extra source capabilities
19
+ * (`getAggregate`, `rows()`) pass through untouched. Returning `applyComputed`
20
+ * rows from every method keeps the controller's optimistic reconciliation
21
+ * correct (a source must return the same row shape from all methods).
22
+ */
23
+ import type { RowData, ServerDataSource } from '@svgrid/grid'
24
+ import { applyComputed, validateEntity, type EntitySchema } from '../schema'
25
+
26
+ function firstError(errors: Record<string, string>): string {
27
+ return Object.values(errors)[0] ?? 'Validation failed'
28
+ }
29
+
30
+ export function withEntityRules<TData extends RowData, S extends ServerDataSource<TData>>(
31
+ source: S,
32
+ schema: EntitySchema<TData>,
33
+ ): S {
34
+ const hooks = schema.hooks ?? {}
35
+ const compute = (row: TData) => applyComputed(schema, row)
36
+
37
+ const wrapped: ServerDataSource<TData> = {
38
+ async getRows(request) {
39
+ const res = await source.getRows(request)
40
+ return { ...res, rows: res.rows.map(compute) }
41
+ },
42
+ }
43
+
44
+ if (source.createRow) {
45
+ const create = source.createRow.bind(source)
46
+ wrapped.createRow = async (input) => {
47
+ const errors = await validateEntity(schema, input)
48
+ if (Object.keys(errors).length) throw new Error(firstError(errors))
49
+ const payload = hooks.beforeCreate ? await hooks.beforeCreate(input) : input
50
+ const row = compute(await create(payload))
51
+ await hooks.afterCreate?.(row)
52
+ return row
53
+ }
54
+ }
55
+
56
+ if (source.updateRow) {
57
+ const update = source.updateRow.bind(source)
58
+ wrapped.updateRow = async (id, patch) => {
59
+ const errors = await validateEntity(schema, patch)
60
+ if (Object.keys(errors).length) throw new Error(firstError(errors))
61
+ const payload = hooks.beforeUpdate ? await hooks.beforeUpdate(id, patch) : patch
62
+ const row = compute(await update(id, payload))
63
+ await hooks.afterUpdate?.(row)
64
+ return row
65
+ }
66
+ }
67
+
68
+ if (source.deleteRow) {
69
+ const del = source.deleteRow.bind(source)
70
+ wrapped.deleteRow = async (id) => {
71
+ await hooks.beforeDelete?.(id)
72
+ await del(id)
73
+ await hooks.afterDelete?.(id)
74
+ }
75
+ }
76
+
77
+ return { ...source, ...wrapped } as S
78
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { EntitySchema } from '../schema'
3
+ import { createInMemoryDataSource } from '../sveltekit/in-memory'
4
+ import { createRelationLookup } from './relation-lookup'
5
+ import { withRelationLabels } from './with-relation-labels'
6
+
7
+ type User = { id: string; name: string }
8
+ type Post = { id: string; title: string; authorId: string; author?: string }
9
+
10
+ const userSchema: EntitySchema<User> = {
11
+ name: 'users', idField: 'id',
12
+ fields: [{ field: 'id', type: 'text', primaryKey: true }, { field: 'name', type: 'text' }],
13
+ }
14
+ const postSchema: EntitySchema<Post> = {
15
+ name: 'posts', idField: 'id',
16
+ fields: [
17
+ { field: 'id', type: 'text', primaryKey: true },
18
+ { field: 'title', type: 'text' },
19
+ { field: 'authorId', type: 'relation', relation: { entity: 'users', labelField: 'name' } },
20
+ { field: 'author', type: 'text', readonly: true },
21
+ ],
22
+ }
23
+
24
+ function setup() {
25
+ const users = createInMemoryDataSource<User>(
26
+ [{ id: 'u1', name: 'Ada' }, { id: 'u2', name: 'Alan' }],
27
+ userSchema,
28
+ )
29
+ const rawPosts = createInMemoryDataSource<Post>(
30
+ [{ id: 'p1', title: 'A', authorId: 'u1' }, { id: 'p2', title: 'B', authorId: 'u2' }],
31
+ postSchema,
32
+ )
33
+ const lookup = createRelationLookup<User>({ source: users, schema: userSchema, labelField: 'name' })
34
+ const posts = withRelationLabels(rawPosts, [{ fk: 'authorId', lookup, as: 'author' }])
35
+ return { posts }
36
+ }
37
+
38
+ describe('withRelationLabels', () => {
39
+ it('decorates getRows with the related label', async () => {
40
+ const { posts } = setup()
41
+ const { rows } = await posts.getRows({ startRow: 0, endRow: 10, pageIndex: 0, pageSize: 10, sortModel: [], filterModel: {} })
42
+ expect(rows.map((r) => r.author)).toEqual(['Ada', 'Alan'])
43
+ })
44
+
45
+ it('decorates the created / updated row (same shape from every method)', async () => {
46
+ const { posts } = setup()
47
+ const created = await posts.createRow!({ id: 'p3', title: 'C', authorId: 'u1' })
48
+ expect(created.author).toBe('Ada')
49
+ const updated = await posts.updateRow!('p3', { authorId: 'u2' })
50
+ expect(updated.author).toBe('Alan')
51
+ })
52
+
53
+ it('is a no-op with no relations', () => {
54
+ const raw = createInMemoryDataSource<Post>([], postSchema)
55
+ expect(withRelationLabels(raw, [])).toBe(raw)
56
+ })
57
+
58
+ it('batches label resolution to one query per page (no N+1)', async () => {
59
+ const users = createInMemoryDataSource<User>([{ id: 'u1', name: 'Ada' }, { id: 'u2', name: 'Alan' }], userSchema)
60
+ const spy = vi.spyOn(users, 'getRows')
61
+ const rawPosts = createInMemoryDataSource<Post>(
62
+ [
63
+ { id: 'p1', title: 'A', authorId: 'u1' },
64
+ { id: 'p2', title: 'B', authorId: 'u2' },
65
+ { id: 'p3', title: 'C', authorId: 'u1' },
66
+ ],
67
+ postSchema,
68
+ )
69
+ const lookup = createRelationLookup<User>({ source: users, schema: userSchema, labelField: 'name' })
70
+ const posts = withRelationLabels(rawPosts, [{ fk: 'authorId', lookup, as: 'author' }])
71
+ const { rows } = await posts.getRows({ startRow: 0, endRow: 10, pageIndex: 0, pageSize: 10, sortModel: [], filterModel: {} })
72
+ expect(rows.map((r) => r.author)).toEqual(['Ada', 'Alan', 'Ada'])
73
+ expect(spy).toHaveBeenCalledTimes(1) // 3 rows, 2 distinct authors -> ONE lookup query
74
+ })
75
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * withRelationLabels - decorate a source's rows with the human label of each
3
+ * foreign key, so the grid can show "Acme Inc" instead of the raw id `42`. It
4
+ * resolves labels through a `RelationLookup` (the same cached resolver the edit
5
+ * form's picker uses), so a value is fetched at most once and stays fresh.
6
+ *
7
+ * const postsSource = withRelationLabels(rawPostsSource, [
8
+ * { fk: 'authorId', lookup: authorLookup, as: 'author' },
9
+ * ])
10
+ *
11
+ * Pair it with a schema where the FK field is `hidden: { grid: true }` and a
12
+ * read-only display column (`author`) shows the label. Because every method
13
+ * returns the decorated row shape, the controller's optimistic updates stay
14
+ * correct.
15
+ */
16
+ import type { RowData, ServerDataSource } from '@svgrid/grid'
17
+ import type { RelationLookup } from './relation-lookup'
18
+
19
+ export type RelationLabelConfig = {
20
+ /** Local foreign-key field to resolve. */
21
+ fk: string
22
+ /** Resolver for the related entity (see `createRelationLookup`). */
23
+ lookup: RelationLookup
24
+ /** Field to write the resolved label onto. */
25
+ as: string
26
+ }
27
+
28
+ export function withRelationLabels<TData extends RowData, S extends ServerDataSource<TData>>(
29
+ source: S,
30
+ relations: RelationLabelConfig[],
31
+ ): S {
32
+ if (relations.length === 0) return source
33
+
34
+ /** Decorate one row (used by create/update returns). */
35
+ const decorate = async (row: TData): Promise<TData> => {
36
+ const copy = { ...(row as RowData) }
37
+ await Promise.all(
38
+ relations.map(async (r) => {
39
+ copy[r.as] = (await r.lookup.labelFor(row[r.fk])) ?? ''
40
+ }),
41
+ )
42
+ return copy as TData
43
+ }
44
+
45
+ const wrapped: ServerDataSource<TData> = {
46
+ async getRows(request) {
47
+ const res = await source.getRows(request)
48
+ // Batch: one lookup query per relation for the whole page (no N+1).
49
+ const maps = await Promise.all(
50
+ relations.map((r) => r.lookup.labelsFor(res.rows.map((row) => row[r.fk]))),
51
+ )
52
+ const rows = res.rows.map((row) => {
53
+ const copy = { ...(row as RowData) }
54
+ relations.forEach((r, i) => {
55
+ copy[r.as] = maps[i]!.get(String(row[r.fk])) ?? ''
56
+ })
57
+ return copy as TData
58
+ })
59
+ return { ...res, rows }
60
+ },
61
+ }
62
+ if (source.createRow) {
63
+ const fn = source.createRow.bind(source)
64
+ wrapped.createRow = async (input) => decorate(await fn(input))
65
+ }
66
+ if (source.updateRow) {
67
+ const fn = source.updateRow.bind(source)
68
+ wrapped.updateRow = async (id, patch) => decorate(await fn(id, patch))
69
+ }
70
+ if (source.deleteRow) wrapped.deleteRow = source.deleteRow.bind(source)
71
+
72
+ return { ...source, ...wrapped } as S
73
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { EntitySchema } from '../schema.js'
3
+ import { createProject, setEntityDataSource } from './project.js'
4
+ import { sanitizeStudioProject, buildStudioBugReport } from './bug-report.js'
5
+
6
+ const SUPABASE_URL = 'https://myproject.supabase.co'
7
+ const SUPABASE_KEY = 'sb_secret_TOP_SECRET_KEY_123'
8
+ const REST_TOKEN = 'Bearer LEAKED_AUTH_TOKEN_XYZ'
9
+ const PRIVATE_ROW = 'Jane Doe SSN 123-45-6789'
10
+
11
+ function entity(name: string): EntitySchema {
12
+ return { name, label: name, idField: 'id', fields: [
13
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
14
+ { field: 'name', type: 'text' },
15
+ ] }
16
+ }
17
+
18
+ function projectWithSecrets() {
19
+ let p = createProject([entity('customers'), entity('orders'), entity('notes')])
20
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: SUPABASE_URL, key: SUPABASE_KEY })
21
+ p = setEntityDataSource(p, 'orders', {
22
+ kind: 'rest', baseUrl: 'https://api.example.com', path: 'orders', method: 'GET',
23
+ params: [
24
+ { name: 'Authorization', location: 'header', type: 'string', value: REST_TOKEN },
25
+ { name: 'region', location: 'query', type: 'string', value: 'eu' },
26
+ ],
27
+ })
28
+ p = setEntityDataSource(p, 'notes', { kind: 'memory', seed: [{ id: 'n1', name: PRIVATE_ROW }] })
29
+ return p
30
+ }
31
+
32
+ describe('sanitizeStudioProject', () => {
33
+ it('strips credentials + seed rows but keeps schema/screens', () => {
34
+ const { project, redactions } = sanitizeStudioProject(projectWithSecrets())
35
+ const json = JSON.stringify(project)
36
+
37
+ // No secret of any kind survives.
38
+ expect(json).not.toContain(SUPABASE_URL)
39
+ expect(json).not.toContain(SUPABASE_KEY)
40
+ expect(json).not.toContain(REST_TOKEN)
41
+ expect(json).not.toContain(PRIVATE_ROW)
42
+
43
+ // Non-secret structure is preserved (repro value).
44
+ expect(json).toContain('"region"') // a non-secret query param name stays
45
+ expect(json).toContain('eu') // ...and its value
46
+ expect(project.entities.map((e) => e.name).sort()).toEqual(['customers', 'notes', 'orders'])
47
+
48
+ // Every removal is reported to the user.
49
+ expect(redactions.join('\n')).toMatch(/Supabase URL redacted/)
50
+ expect(redactions.join('\n')).toMatch(/Supabase key redacted/)
51
+ expect(redactions.join('\n')).toMatch(/Authorization.*redacted/)
52
+ expect(redactions.join('\n')).toMatch(/seed row\(s\) omitted/)
53
+ expect(redactions.length).toBe(4)
54
+ })
55
+
56
+ it('leaves a project with no secrets unchanged (no false redactions)', () => {
57
+ const p = createProject([entity('a')])
58
+ const { redactions } = sanitizeStudioProject(p)
59
+ // createProject defaults to a memory source with generated seed rows -> that
60
+ // seed IS data, so it's the only thing (if any) reported; never a credential.
61
+ expect(redactions.every((r) => !/redacted/i.test(r) || /seed/i.test(r))).toBe(true)
62
+ })
63
+ })
64
+
65
+ describe('buildStudioBugReport', () => {
66
+ it('never leaks secrets into the markdown or the prefilled issue URL', () => {
67
+ const report = buildStudioBugReport({
68
+ project: projectWithSecrets(),
69
+ error: new Error('Cannot read properties of undefined (reading "blocks")'),
70
+ action: 'generating the app',
71
+ env: { studioVersion: '0.1.0', node: 'v22.0.0', os: 'win32' },
72
+ })
73
+ for (const secret of [SUPABASE_URL, SUPABASE_KEY, REST_TOKEN, PRIVATE_ROW]) {
74
+ expect(report.markdown).not.toContain(secret)
75
+ expect(report.issueUrl).not.toContain(encodeURIComponent(secret))
76
+ expect(report.issueUrl).not.toContain(secret)
77
+ }
78
+ expect(report.title).toContain('[Studio]')
79
+ expect(report.issueUrl).toContain('https://github.com/sv-grid/sv-grid/issues/new')
80
+ expect(report.markdown).toContain('Sanitized studio.config.json')
81
+ expect(report.redactions.length).toBe(4)
82
+ })
83
+
84
+ it('truncates the URL for a large project (falls back to paste-the-markdown)', () => {
85
+ // A project big enough that the full report exceeds the URL cap.
86
+ const many = Array.from({ length: 40 }, (_, i) => entity(`e${i}`))
87
+ let p = createProject(many)
88
+ for (const e of many) p = setEntityDataSource(p, e.name, { kind: 'memory', seed: [] })
89
+ const report = buildStudioBugReport({ project: p, env: {} })
90
+ expect(report.truncated).toBe(true)
91
+ expect(report.issueUrl.length).toBeLessThanOrEqual(7500)
92
+ expect(report.issueUrl).toContain('issues/new')
93
+ })
94
+
95
+ it('works with no project (a plain crash report)', () => {
96
+ const report = buildStudioBugReport({ error: 'boom', env: { studioVersion: '0.1.0' } })
97
+ expect(report.title).toBe('[Studio] boom')
98
+ expect(report.markdown).toContain('boom')
99
+ expect(report.redactions).toEqual([])
100
+ })
101
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Studio bug-report core (pure + node-safe, usable from the CLI and the
3
+ * designer). Two pieces:
4
+ *
5
+ * - `sanitizeStudioProject` - strip credentials + seed DATA from a project so
6
+ * a bug report never leaks a user's database or rows. It keeps the SCHEMA,
7
+ * screens, and block structure (what actually reproduces a Studio bug) and
8
+ * returns a plain-language list of what was removed, so the user sees it.
9
+ * - `buildStudioBugReport` - assemble a markdown report + a prefilled GitHub
10
+ * "new issue" URL. The URL carries a short summary (GitHub caps the prefill
11
+ * ~8 KB); the full sanitized report goes in `markdown` for the caller to
12
+ * copy to the clipboard / write to a file and paste in.
13
+ *
14
+ * The caller supplies `env` (version / node / os) so this stays free of any
15
+ * process or DOM access and is trivially testable.
16
+ */
17
+ import type { EntityDataSource, StudioProject } from './project.js'
18
+
19
+ const REDACTED = '<redacted>'
20
+ const DEFAULT_REPO = 'sv-grid/sv-grid'
21
+ /** Keep the prefilled issue URL comfortably under browser / GitHub limits. */
22
+ const MAX_URL = 7500
23
+ /** Param names that carry secrets (substring, case-insensitive). */
24
+ const SECRET_NAME_RE = /(key|token|secret|auth|password|pass|bearer|credential)/i
25
+
26
+ export type SanitizeResult = { project: StudioProject; redactions: string[] }
27
+
28
+ /** Redact one entity's data source; push a human note for anything removed. */
29
+ function sanitizeSource(entity: string, src: EntityDataSource, redactions: string[]): EntityDataSource {
30
+ if (src.kind === 'memory') {
31
+ const { seed, ...rest } = src
32
+ if (seed?.length) redactions.push(`${entity}: ${seed.length} in-memory seed row(s) omitted`)
33
+ return rest
34
+ }
35
+ if (src.kind === 'pglite') {
36
+ const { seed, ...rest } = src
37
+ if (seed?.length) redactions.push(`${entity}: ${seed.length} local-database seed row(s) omitted`)
38
+ return rest
39
+ }
40
+ if (src.kind === 'supabase') {
41
+ const out = { ...src }
42
+ if (out.url) { out.url = REDACTED; redactions.push(`${entity}: Supabase URL redacted`) }
43
+ if (out.key) { out.key = REDACTED; redactions.push(`${entity}: Supabase key redacted`) }
44
+ return out
45
+ }
46
+ if (src.kind === 'rest') {
47
+ const params = src.params.map((p) => {
48
+ // Header params and secret-named params carry auth; drop their values.
49
+ if (p.value && (p.location === 'header' || SECRET_NAME_RE.test(p.name))) {
50
+ redactions.push(`${entity}: REST ${p.location} param "${p.name}" value redacted`)
51
+ return { ...p, value: REDACTED }
52
+ }
53
+ return p
54
+ })
55
+ let baseUrl = src.baseUrl
56
+ if (/[?&](key|token|apikey|secret|auth|password)=/i.test(baseUrl)) {
57
+ baseUrl = baseUrl.replace(/([?&](?:key|token|apikey|secret|auth|password)=)[^&]*/gi, `$1${REDACTED}`)
58
+ redactions.push(`${entity}: credentials in REST baseUrl redacted`)
59
+ }
60
+ return { ...src, baseUrl, params }
61
+ }
62
+ // sql: the connection string lives in the host env, not the model - nothing to strip.
63
+ return src
64
+ }
65
+
66
+ /**
67
+ * Return a copy of the project safe to attach to a bug report: credentials and
68
+ * seeded rows removed, schema + screens + blocks intact. Also returns a list of
69
+ * exactly what was stripped, for transparency.
70
+ */
71
+ export function sanitizeStudioProject(project: StudioProject): SanitizeResult {
72
+ const redactions: string[] = []
73
+ if (!project.dataSources) return { project, redactions }
74
+ const nextSources: Record<string, EntityDataSource> = {}
75
+ for (const [name, src] of Object.entries(project.dataSources)) {
76
+ nextSources[name] = sanitizeSource(name, src, redactions)
77
+ }
78
+ return { project: { ...project, dataSources: nextSources }, redactions }
79
+ }
80
+
81
+ export type StudioEnv = { studioVersion?: string; node?: string; os?: string }
82
+
83
+ export type BugReportInput = {
84
+ /** The current project model (studio.config.json). Sanitized before use. */
85
+ project?: StudioProject
86
+ /** The error that triggered the report (on a crash), if any. */
87
+ error?: Error | { message: string; stack?: string } | string
88
+ /** What the user was doing (e.g. "generating the app", "editing the CRM board"). */
89
+ action?: string
90
+ env?: StudioEnv
91
+ /** `owner/repo` the issue targets (default the public SvGrid repo). */
92
+ repo?: string
93
+ }
94
+
95
+ export type BugReport = {
96
+ title: string
97
+ /** Full markdown report - copy to clipboard / write to a file and paste in. */
98
+ markdown: string
99
+ /** Prefilled GitHub "new issue" URL. */
100
+ issueUrl: string
101
+ redactions: string[]
102
+ /** True when the report was too big for the URL, so `markdown` must be pasted. */
103
+ truncated: boolean
104
+ }
105
+
106
+ function normalizeError(e: BugReportInput['error']): { message: string; stack?: string } | undefined {
107
+ if (!e) return undefined
108
+ if (typeof e === 'string') return { message: e }
109
+ return { message: e.message, stack: 'stack' in e ? e.stack : undefined }
110
+ }
111
+
112
+ function newIssueUrl(repo: string, title: string, body: string): string {
113
+ const q = new URLSearchParams({ title, body, labels: 'studio,bug' })
114
+ return `https://github.com/${repo}/issues/new?${q.toString()}`
115
+ }
116
+
117
+ function envLines(env: StudioEnv): string[] {
118
+ return [
119
+ `- Studio: ${env.studioVersion ?? 'unknown'}`,
120
+ `- Node: ${env.node ?? 'unknown'}`,
121
+ `- OS: ${env.os ?? 'unknown'}`,
122
+ ]
123
+ }
124
+
125
+ function renderSummary(env: StudioEnv, err: ReturnType<typeof normalizeError>, action: string | undefined, redactions: string[]): string {
126
+ const lines = ['### Environment', ...envLines(env), '']
127
+ if (action) lines.push('### What I was doing', action, '')
128
+ if (err) lines.push('### Error', '```', err.message, '```', '')
129
+ lines.push(`_${redactions.length} item(s) redacted (credentials + seed data)._`)
130
+ return lines.join('\n')
131
+ }
132
+
133
+ /**
134
+ * Build a shareable bug report from the current Studio state. The project is
135
+ * sanitized first; secrets never reach the markdown or the URL.
136
+ */
137
+ export function buildStudioBugReport(input: BugReportInput): BugReport {
138
+ const env = input.env ?? {}
139
+ const err = normalizeError(input.error)
140
+ const { project, redactions } = input.project
141
+ ? sanitizeStudioProject(input.project)
142
+ : { project: undefined as StudioProject | undefined, redactions: [] as string[] }
143
+
144
+ const firstLine = (err?.message ?? 'Bug report').split('\n')[0]!.trim().slice(0, 100)
145
+ const title = `[Studio] ${firstLine}`
146
+
147
+ const md: string[] = ['## SvGrid Studio bug report', '', '### Environment', ...envLines(env), '']
148
+ if (input.action) md.push('### What I was doing', input.action, '')
149
+ if (err) md.push('### Error', '```', err.message, ...(err.stack ? ['', err.stack] : []), '```', '')
150
+ if (project) md.push('### Project', `${project.entities?.length ?? 0} entities, ${project.screens?.length ?? 0} screens.`, '')
151
+ md.push('### Redactions', redactions.length ? redactions.map((r) => `- ${r}`).join('\n') : '- none', '')
152
+ if (project) md.push('### Sanitized studio.config.json', '```json', JSON.stringify(project, null, 2), '```')
153
+ const markdown = md.join('\n')
154
+
155
+ const repo = input.repo ?? DEFAULT_REPO
156
+ let issueUrl = newIssueUrl(repo, title, markdown)
157
+ let truncated = false
158
+ if (issueUrl.length > MAX_URL) {
159
+ truncated = true
160
+ const body = `${renderSummary(env, err, input.action, redactions)}\n\n> The full sanitized report was copied to your clipboard - paste it below.`
161
+ issueUrl = newIssueUrl(repo, title, body)
162
+ }
163
+
164
+ return { title, markdown, issueUrl, redactions, truncated }
165
+ }