@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- 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
|
+
}
|