@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,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ServerRequest } from '@svgrid/grid'
3
+ import type { EntitySchema } from '../schema'
4
+ import { createInMemoryDataSource } from './in-memory'
5
+ import { createKitDataSource, createKitHandlers } from './transport'
6
+
7
+ type Customer = { id: string; name: string; age: number; tier: string }
8
+
9
+ const schema: EntitySchema<Customer> = {
10
+ name: 'customers',
11
+ fields: [
12
+ { field: 'id', type: 'text', primaryKey: true },
13
+ { field: 'name', type: 'text' },
14
+ { field: 'age', type: 'number' },
15
+ { field: 'tier', type: 'enum' },
16
+ ],
17
+ }
18
+
19
+ const seed: Customer[] = [
20
+ { id: '1', name: 'Ann', age: 30, tier: 'pro' },
21
+ { id: '2', name: 'Bob', age: 41, tier: 'free' },
22
+ ]
23
+
24
+ /**
25
+ * Stand up the full loop in-process: an in-memory backend behind the server
26
+ * handlers, and a client whose `fetch` routes straight into those handlers via
27
+ * a real `Request`. This exercises the wire protocol end to end without a
28
+ * running SvelteKit server.
29
+ */
30
+ function wireClientToServer() {
31
+ const backend = createInMemoryDataSource(seed, schema)
32
+ const handlers = createKitHandlers({ schema, source: backend })
33
+ const fakeFetch = (url: string, init?: RequestInit) =>
34
+ handlers.handle(new Request(`http://localhost${url}`, init))
35
+ const client = createKitDataSource<Customer>({ endpoint: '/api/customers', fetch: fakeFetch })
36
+ return { client, backend }
37
+ }
38
+
39
+ const req = (partial: Partial<ServerRequest>): ServerRequest => ({
40
+ startRow: 0,
41
+ endRow: 50,
42
+ pageIndex: 0,
43
+ pageSize: 50,
44
+ sortModel: [],
45
+ filterModel: {},
46
+ ...partial,
47
+ })
48
+
49
+ describe('SvelteKit transport round trip', () => {
50
+ it('getRows travels client -> server -> backend and back', async () => {
51
+ const { client } = wireClientToServer()
52
+ const res = await client.getRows(req({ sortModel: [{ id: 'age', desc: true }] }))
53
+ expect(res.rowCount).toBe(2)
54
+ expect(res.rows[0]?.name).toBe('Bob')
55
+ })
56
+
57
+ it('createRow / updateRow / deleteRow mutate the backend through the protocol', async () => {
58
+ const { client, backend } = wireClientToServer()
59
+
60
+ const created = await client.createRow({ id: '3', name: 'Cara', age: 25, tier: 'pro' })
61
+ expect(created.name).toBe('Cara')
62
+ expect(backend.rows()).toHaveLength(3)
63
+
64
+ const updated = await client.updateRow('3', { age: 26 })
65
+ expect(updated.age).toBe(26)
66
+
67
+ await client.deleteRow('1')
68
+ expect(backend.rows().some((r) => r.id === '1')).toBe(false)
69
+ })
70
+
71
+ it('surfaces a backend error as a rejected fetch', async () => {
72
+ const { client } = wireClientToServer()
73
+ // updating a non-existent id makes the in-memory source throw -> 500
74
+ await expect(client.updateRow('nope', { age: 1 })).rejects.toThrow(/svgrid kit: 500/)
75
+ })
76
+
77
+ it('returns 405 when the backend lacks a writer', async () => {
78
+ const readOnly = { getRows: async () => ({ rows: [], rowCount: 0 }) }
79
+ const handlers = createKitHandlers({ schema, source: readOnly })
80
+ const client = createKitDataSource<Customer>({
81
+ endpoint: '/x',
82
+ fetch: (url, init) => handlers.handle(new Request(`http://localhost${url}`, init)),
83
+ })
84
+ await expect(client.createRow({ name: 'x' })).rejects.toThrow(/405/)
85
+ })
86
+ })
87
+
88
+ describe('createKitHandlers server-enforced validation', () => {
89
+ const vSchema: EntitySchema<Customer> = {
90
+ name: 'customers',
91
+ fields: [
92
+ { field: 'id', type: 'text', primaryKey: true },
93
+ { field: 'name', type: 'text', required: true },
94
+ { field: 'age', type: 'number', min: 0, max: 120 },
95
+ { field: 'tier', type: 'enum' },
96
+ ],
97
+ }
98
+ const post = (h: ReturnType<typeof createKitHandlers>, body: unknown) =>
99
+ h.handle(new Request('http://localhost/api/customers', { method: 'POST', body: JSON.stringify(body) }))
100
+
101
+ it('rejects a constraint-violating create with 422 + fieldErrors', async () => {
102
+ const handlers = createKitHandlers({ schema: vSchema, source: createInMemoryDataSource<Customer>([], vSchema), validate: true })
103
+ const res = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', age: 999 } })
104
+ expect(res.status).toBe(422)
105
+ const body = await res.json()
106
+ expect(body.fieldErrors.name).toBeTruthy() // required missing
107
+ expect(body.fieldErrors.age).toBeTruthy() // above max
108
+ })
109
+
110
+ it('accepts a valid create', async () => {
111
+ const handlers = createKitHandlers({ schema: vSchema, source: createInMemoryDataSource<Customer>([], vSchema), validate: true })
112
+ const res = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', name: 'Zoe', age: 22 } })
113
+ expect(res.status).toBe(200)
114
+ })
115
+
116
+ it('validates only the fields present in an update patch', async () => {
117
+ const handlers = createKitHandlers({ schema: vSchema, source: createInMemoryDataSource<Customer>(seed, vSchema), validate: true })
118
+ // omits required `name` (fine - not in the patch), valid age -> 200
119
+ expect((await post(handlers, { kind: 'mutate', op: 'update', id: '1', patch: { age: 33 } })).status).toBe(200)
120
+ // bad age in the patch -> 422
121
+ expect((await post(handlers, { kind: 'mutate', op: 'update', id: '1', patch: { age: -5 } })).status).toBe(422)
122
+ })
123
+
124
+ it('is off by default (no validation unless opted in)', async () => {
125
+ const handlers = createKitHandlers({ schema: vSchema, source: createInMemoryDataSource<Customer>([], vSchema) })
126
+ const res = await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', age: 999 } })
127
+ expect(res.status).toBe(200)
128
+ })
129
+ })
130
+
131
+ describe('createKitHandlers audit hook', () => {
132
+ const post = (h: ReturnType<typeof createKitHandlers>, b: unknown) =>
133
+ h.handle(new Request('http://localhost/api/customers', { method: 'POST', body: JSON.stringify(b) }))
134
+
135
+ it('fires after create / update / delete with the change details', async () => {
136
+ const log: Array<{ action: string; id: string | null; values?: unknown }> = []
137
+ const handlers = createKitHandlers({ schema, source: createInMemoryDataSource(seed, schema), audit: (e) => { log.push({ action: e.action, id: e.id, values: e.values }) } })
138
+ await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', name: 'Zoe', age: 20, tier: 'pro' } })
139
+ await post(handlers, { kind: 'mutate', op: 'update', id: '9', patch: { age: 21 } })
140
+ await post(handlers, { kind: 'mutate', op: 'delete', id: '9' })
141
+ expect(log.map((e) => e.action)).toEqual(['create', 'update', 'delete'])
142
+ expect(log[0]!.id).toBe('9')
143
+ expect(log[1]!.values).toEqual({ age: 21 })
144
+ })
145
+
146
+ it('never fires on reads, and a throwing audit sink does not fail the write', async () => {
147
+ let fired = 0
148
+ const handlers = createKitHandlers({ schema, source: createInMemoryDataSource(seed, schema), audit: () => { fired++; throw new Error('sink down') } })
149
+ expect((await post(handlers, { kind: 'query', request: req({}) })).status).toBe(200)
150
+ expect(fired).toBe(0)
151
+ expect((await post(handlers, { kind: 'mutate', op: 'create', input: { id: '9', name: 'Z', age: 1, tier: 'x' } })).status).toBe(200)
152
+ expect(fired).toBe(1) // audit ran, but the write still succeeded
153
+ })
154
+ })
155
+
156
+ describe('createKitHandlers authorize (RBAC seam)', () => {
157
+ const body = (b: unknown, init: RequestInit = {}) =>
158
+ new Request('http://localhost/api/customers', { method: 'POST', body: JSON.stringify(b), ...init })
159
+
160
+ it('reads pass but writes 403 when authorize denies them', async () => {
161
+ const handlers = createKitHandlers({ schema, source: createInMemoryDataSource(seed, schema), authorize: ({ action }) => action === 'read' })
162
+ expect((await handlers.handle(body({ kind: 'query', request: req({}) }))).status).toBe(200)
163
+ expect((await handlers.handle(body({ kind: 'mutate', op: 'delete', id: '1' }))).status).toBe(403)
164
+ })
165
+
166
+ it('resolves the role from event.locals', async () => {
167
+ const handlers = createKitHandlers({
168
+ schema,
169
+ source: createInMemoryDataSource(seed, schema),
170
+ authorize: ({ action, event }) => action === 'read' || event.locals?.role === 'admin',
171
+ })
172
+ expect((await handlers.handle(body({ kind: 'mutate', op: 'delete', id: '1' }), { locals: { role: 'viewer' } })).status).toBe(403)
173
+ expect((await handlers.handle(body({ kind: 'mutate', op: 'delete', id: '2' }), { locals: { role: 'admin' } })).status).toBe(200)
174
+ })
175
+ })
@@ -0,0 +1,254 @@
1
+ /**
2
+ * SvelteKit transport - the deep integration an agnostic grid can't ship.
3
+ *
4
+ * Two halves that speak one small JSON protocol over a single route:
5
+ *
6
+ * - `createKitDataSource({ endpoint })` runs in the browser (or a `load`
7
+ * function) and returns a `ServerDataSource` that POSTs requests to your
8
+ * endpoint. Drop it straight into `createServerDataSource`.
9
+ *
10
+ * - `createKitHandlers({ schema, source })` runs on the server and returns a
11
+ * `POST` handler for a `+server.ts` route. It parses the protocol, calls
12
+ * your backend `ServerDataSource` (in-memory, SQL, Drizzle, ...), and
13
+ * returns JSON.
14
+ *
15
+ * Example `src/routes/api/customers/+server.ts`:
16
+ *
17
+ * import { createKitHandlers } from '@svgrid/enterprise'
18
+ * import { customerSchema, customerSource } from '$lib/customers'
19
+ * export const { POST } = createKitHandlers({ schema: customerSchema, source: customerSource })
20
+ *
21
+ * Client:
22
+ *
23
+ * const source = createKitDataSource({ endpoint: '/api/customers' })
24
+ * const controller = createServerDataSource(source, { onChange: ... })
25
+ */
26
+ import type {
27
+ RowData,
28
+ ServerDataSource,
29
+ ServerRequest,
30
+ ServerResult,
31
+ } from '@svgrid/grid'
32
+ import type { EntitySchema } from '../schema'
33
+ import { validateAll } from '../edit-panel'
34
+ import type { WritableDataSource } from './types'
35
+
36
+ /** Wire protocol: the single JSON body shape the client POSTs and the server dispatches on. */
37
+ export type KitMessage<TData> =
38
+ | { kind: 'query'; request: ServerRequest }
39
+ | { kind: 'mutate'; op: 'create'; input: Partial<TData> }
40
+ | { kind: 'mutate'; op: 'update'; id: string; patch: Partial<TData> }
41
+ | { kind: 'mutate'; op: 'delete'; id: string }
42
+
43
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
44
+
45
+ function jsonResponse(data: unknown, status = 200): Response {
46
+ return new Response(JSON.stringify(data), {
47
+ status,
48
+ headers: { 'content-type': 'application/json' },
49
+ })
50
+ }
51
+
52
+ // --- client ---------------------------------------------------------------
53
+
54
+ export type KitDataSourceOptions = {
55
+ /** Route that `createKitHandlers` is mounted on, e.g. `'/api/customers'`. */
56
+ endpoint: string
57
+ /**
58
+ * Fetch implementation. Pass SvelteKit's event `fetch` inside a `load` so
59
+ * requests are same-origin and SSR-safe; defaults to the global `fetch`.
60
+ */
61
+ fetch?: FetchLike
62
+ }
63
+
64
+ export function createKitDataSource<TData extends RowData>(
65
+ options: KitDataSourceOptions,
66
+ ): WritableDataSource<TData> {
67
+ const doFetch: FetchLike = options.fetch ?? ((i, init) => fetch(i, init))
68
+
69
+ async function post<T>(body: KitMessage<TData>): Promise<T> {
70
+ const res = await doFetch(options.endpoint, {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify(body),
74
+ })
75
+ if (!res.ok) {
76
+ const detail = await res.text().catch(() => '')
77
+ throw new Error(`svgrid kit: ${res.status} ${res.statusText}${detail ? ` - ${detail}` : ''}`)
78
+ }
79
+ return (await res.json()) as T
80
+ }
81
+
82
+ return {
83
+ getRows: (request) => post<ServerResult<TData>>({ kind: 'query', request }),
84
+ createRow: (input) => post<TData>({ kind: 'mutate', op: 'create', input }),
85
+ updateRow: (id, patch) => post<TData>({ kind: 'mutate', op: 'update', id, patch }),
86
+ deleteRow: (id) => post<void>({ kind: 'mutate', op: 'delete', id }).then(() => undefined),
87
+ }
88
+ }
89
+
90
+ // --- server ---------------------------------------------------------------
91
+
92
+ /** The CRUD action a Kit message maps to (`query` -> `read`). */
93
+ export type KitAction = 'read' | 'create' | 'update' | 'delete'
94
+ /** Context passed to the `authorize` guard before every op. */
95
+ export type KitAuthorizeContext<TData extends RowData> = {
96
+ action: KitAction
97
+ /** The SvelteKit request event, so you can read `locals` / session for the
98
+ * caller's role or user id. Only `request` is guaranteed in non-Kit callers. */
99
+ event: { request: Request; locals?: Record<string, unknown> }
100
+ message: KitMessage<TData>
101
+ }
102
+
103
+ /** A field-error map (`{ field: message }`); empty/undefined means valid. */
104
+ export type KitFieldErrors = Record<string, string>
105
+ /** Context passed to a custom `validate` function before a create / update. */
106
+ export type KitValidateContext<TData extends RowData> = {
107
+ action: 'create' | 'update'
108
+ values: Partial<TData>
109
+ event: { request: Request; locals?: Record<string, unknown> }
110
+ }
111
+
112
+ export type KitHandlerOptions<TData extends RowData> = {
113
+ schema: EntitySchema<TData>
114
+ source: ServerDataSource<TData>
115
+ /**
116
+ * Optional server-side guard run BEFORE every op. Return `false` (or throw) to
117
+ * reject the request with `403`. Receives the SvelteKit event, so you can read
118
+ * `event.locals` / the session to resolve the caller's role - this is where
119
+ * server-enforced authorization (RBAC) lives, independent of any client UI.
120
+ */
121
+ authorize?: (ctx: KitAuthorizeContext<TData>) => boolean | Promise<boolean>
122
+ /**
123
+ * Server-enforced validation, run BEFORE a create / update reaches the source.
124
+ * - `true`: validate the payload against the `schema`'s declarative constraints
125
+ * (required, min/max, length, pattern, email/url, per-field + cross-field
126
+ * rules) via `validateAll`. Updates validate only the fields present in the
127
+ * patch. Defect payloads are rejected with `422 { error, fieldErrors }`.
128
+ * - a function: your own check returning a `{ field: message }` map (non-empty
129
+ * -> 422).
130
+ * Default off, so existing routes are unchanged; the Studio generator turns it
131
+ * on. Enforcing here means a client that skips its form validation still can't
132
+ * write bad data.
133
+ */
134
+ validate?: boolean | ((ctx: KitValidateContext<TData>) => KitFieldErrors | null | undefined | Promise<KitFieldErrors | null | undefined>)
135
+ /**
136
+ * Called AFTER a successful create / update / delete with the change details,
137
+ * so you can write an audit record (who / what / when). Receives the SvelteKit
138
+ * event for `locals` (the actor). Best-effort: a throw here is swallowed and
139
+ * logged, so an audit-sink hiccup never fails the underlying write.
140
+ */
141
+ audit?: (entry: KitAuditEntry<TData>) => void | Promise<void>
142
+ }
143
+
144
+ /** The change details handed to the `audit` hook after a successful mutation. */
145
+ export type KitAuditEntry<TData extends RowData> = {
146
+ action: 'create' | 'update' | 'delete'
147
+ /** The affected row's id. */
148
+ id: string | null
149
+ /** Submitted values: create input / update patch. Undefined for delete. */
150
+ values?: Partial<TData>
151
+ /** The row the source returned (create / update). Undefined for delete. */
152
+ result?: TData
153
+ event: { request: Request; locals?: Record<string, unknown> }
154
+ }
155
+
156
+ export type KitHandlers = {
157
+ /** SvelteKit `+server.ts` route handler. Export it as `export const { POST } = ...`. */
158
+ POST: (event: { request: Request; locals?: Record<string, unknown> }) => Promise<Response>
159
+ /** Lower-level dispatcher over a raw `Request`, for custom wiring or tests. */
160
+ handle: (request: Request, event?: { locals?: Record<string, unknown> }) => Promise<Response>
161
+ }
162
+
163
+ const actionOf = <TData extends RowData>(msg: KitMessage<TData>): KitAction =>
164
+ msg.kind === 'query' ? 'read' : msg.op
165
+
166
+ export function createKitHandlers<TData extends RowData>(
167
+ options: KitHandlerOptions<TData>,
168
+ ): KitHandlers {
169
+ const { source, authorize, validate, audit } = options
170
+ const idField = options.schema.idField ?? options.schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
171
+ const rowId = (row: unknown): string | null => {
172
+ const v = (row as Record<string, unknown> | null)?.[idField]
173
+ return v == null ? null : String(v)
174
+ }
175
+ // Best-effort: never let an audit-sink error fail the write it's recording.
176
+ const fireAudit = async (entry: KitAuditEntry<TData>) => {
177
+ if (!audit) return
178
+ try { await audit(entry) } catch (err) { console.error('svgrid kit: audit hook failed', err) }
179
+ }
180
+
181
+ async function handle(request: Request, event?: { locals?: Record<string, unknown> }): Promise<Response> {
182
+ let msg: KitMessage<TData>
183
+ try {
184
+ msg = (await request.json()) as KitMessage<TData>
185
+ } catch {
186
+ return jsonResponse({ error: 'invalid JSON body' }, 400)
187
+ }
188
+
189
+ // Authorize BEFORE touching the source. A thrown guard is treated as a deny.
190
+ if (authorize) {
191
+ let allowed: boolean
192
+ try {
193
+ allowed = await authorize({ action: actionOf(msg), event: { request, locals: event?.locals }, message: msg })
194
+ } catch (err) {
195
+ return jsonResponse({ error: err instanceof Error ? err.message : 'forbidden' }, 403)
196
+ }
197
+ if (!allowed) return jsonResponse({ error: 'forbidden' }, 403)
198
+ }
199
+
200
+ // Validate create / update payloads BEFORE the source. 422 on field errors.
201
+ if (validate && msg.kind === 'mutate' && (msg.op === 'create' || msg.op === 'update')) {
202
+ const values = (msg.op === 'create' ? msg.input : msg.patch) as Partial<TData>
203
+ let errors: KitFieldErrors | null | undefined
204
+ if (validate === true) {
205
+ const all = await validateAll(options.schema, values as Record<string, unknown>)
206
+ // On update, only the supplied fields are validated (a patch omits the rest).
207
+ errors = msg.op === 'update'
208
+ ? Object.fromEntries(Object.keys(values).filter((k) => all[k]).map((k) => [k, all[k]!]))
209
+ : all
210
+ } else {
211
+ errors = await validate({ action: msg.op, values, event: { request, locals: event?.locals } })
212
+ }
213
+ if (errors && Object.keys(errors).length) {
214
+ return jsonResponse({ error: 'validation failed', fieldErrors: errors }, 422)
215
+ }
216
+ }
217
+
218
+ try {
219
+ if (msg.kind === 'query') {
220
+ const result = await source.getRows(msg.request)
221
+ return jsonResponse(result)
222
+ }
223
+ if (msg.kind === 'mutate') {
224
+ const evt = { request, locals: event?.locals }
225
+ if (msg.op === 'create') {
226
+ if (!source.createRow) return jsonResponse({ error: 'create not supported' }, 405)
227
+ const row = await source.createRow(msg.input)
228
+ await fireAudit({ action: 'create', id: rowId(row), values: msg.input, result: row, event: evt })
229
+ return jsonResponse(row)
230
+ }
231
+ if (msg.op === 'update') {
232
+ if (!source.updateRow) return jsonResponse({ error: 'update not supported' }, 405)
233
+ const row = await source.updateRow(msg.id, msg.patch)
234
+ await fireAudit({ action: 'update', id: msg.id, values: msg.patch, result: row, event: evt })
235
+ return jsonResponse(row)
236
+ }
237
+ if (msg.op === 'delete') {
238
+ if (!source.deleteRow) return jsonResponse({ error: 'delete not supported' }, 405)
239
+ await source.deleteRow(msg.id)
240
+ await fireAudit({ action: 'delete', id: msg.id, event: evt })
241
+ return jsonResponse({ ok: true })
242
+ }
243
+ }
244
+ return jsonResponse({ error: 'unknown message kind' }, 400)
245
+ } catch (err) {
246
+ return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500)
247
+ }
248
+ }
249
+
250
+ return {
251
+ handle,
252
+ POST: (event) => handle(event.request, event),
253
+ }
254
+ }
@@ -0,0 +1,11 @@
1
+ import type { RowData, ServerDataSource } from '@svgrid/grid'
2
+
3
+ /**
4
+ * A `ServerDataSource` that implements the full CRUD contract - the optional
5
+ * `createRow` / `updateRow` / `deleteRow` are required here. Sources that
6
+ * always support writes (the in-memory reference source, the SvelteKit client)
7
+ * return this so callers get the methods without a null check.
8
+ */
9
+ export type WritableDataSource<TData extends RowData> = Required<
10
+ Pick<ServerDataSource<TData>, 'getRows' | 'createRow' | 'updateRow' | 'deleteRow'>
11
+ >