@svgrid/enterprise 2.2.1 → 2.3.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/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import an OpenAPI 3.x document (JSON) into Studio entities + REST data sources.
|
|
3
|
+
* Point at a spec and get a modelled app: each REST resource ( `GET /pets` +
|
|
4
|
+
* `GET /pets/{id}` + POST/PUT/DELETE ) becomes one EntitySchema and one
|
|
5
|
+
* `RestSource`, with fields mapped from the resource's JSON Schema.
|
|
6
|
+
*
|
|
7
|
+
* v1 scope: OpenAPI 3.x JSON only (YAML deferred), single-file `$ref` into
|
|
8
|
+
* `components/schemas`. Pure + Svelte-free like the rest of the studio core.
|
|
9
|
+
*/
|
|
10
|
+
import type { EntityField, EntityFieldType, EntitySchema } from '../schema.js'
|
|
11
|
+
import type { EntityDataSource, RequestParam, RestSource } from './project.js'
|
|
12
|
+
|
|
13
|
+
type Json = Record<string, unknown>
|
|
14
|
+
type OpenApiDoc = {
|
|
15
|
+
openapi?: string
|
|
16
|
+
servers?: Array<{ url?: string }>
|
|
17
|
+
paths?: Record<string, Json>
|
|
18
|
+
components?: { schemas?: Record<string, Json> }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type OpenApiImport = {
|
|
22
|
+
entities: EntitySchema[]
|
|
23
|
+
sources: Record<string, EntityDataSource>
|
|
24
|
+
/** Non-fatal notes (skipped paths, unresolved refs) to surface in the UI. */
|
|
25
|
+
warnings: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
|
|
29
|
+
|
|
30
|
+
/** Resolve a local `#/components/schemas/Foo` ref (one level). */
|
|
31
|
+
function resolveRef(doc: OpenApiDoc, ref: string): Json | null {
|
|
32
|
+
const m = ref.match(/^#\/components\/schemas\/(.+)$/)
|
|
33
|
+
if (!m) return null
|
|
34
|
+
return (doc.components?.schemas?.[m[1]!] as Json) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Follow a `$ref` to its target schema (guards against a self/cyclic ref). */
|
|
38
|
+
function deref(doc: OpenApiDoc, node: Json | undefined, seen = new Set<string>()): Json | null {
|
|
39
|
+
if (!node) return null
|
|
40
|
+
const ref = node['$ref']
|
|
41
|
+
if (typeof ref === 'string') {
|
|
42
|
+
if (seen.has(ref)) return null
|
|
43
|
+
seen.add(ref)
|
|
44
|
+
return deref(doc, resolveRef(doc, ref) ?? undefined, seen)
|
|
45
|
+
}
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The component-schema name a `$ref` points at, or null. */
|
|
50
|
+
function refName(node: Json | undefined): string | null {
|
|
51
|
+
const ref = node?.['$ref']
|
|
52
|
+
const m = typeof ref === 'string' ? ref.match(/^#\/components\/schemas\/(.+)$/) : null
|
|
53
|
+
return m ? m[1]! : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const titleCase = (s: string) => s.replace(/[-_]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).trim()
|
|
57
|
+
const singular = (s: string) => (s.endsWith('ies') ? s.slice(0, -3) + 'y' : s.endsWith('ses') ? s.slice(0, -2) : s.endsWith('s') && !s.endsWith('ss') ? s.slice(0, -1) : s)
|
|
58
|
+
|
|
59
|
+
/** Map one JSON-Schema property to an EntityField type (+ enum options). */
|
|
60
|
+
function fieldType(doc: OpenApiDoc, prop: Json, entityByRef: Map<string, string>): { type: EntityFieldType; options?: Array<{ value: string; label: string }>; relation?: string } {
|
|
61
|
+
// A `$ref` to a schema that became one of our entities -> a relation.
|
|
62
|
+
const rn = refName(prop)
|
|
63
|
+
if (rn && entityByRef.has(rn)) return { type: 'relation', relation: entityByRef.get(rn)! }
|
|
64
|
+
const schema = deref(doc, prop) ?? prop
|
|
65
|
+
const enumVals = schema['enum']
|
|
66
|
+
if (Array.isArray(enumVals) && enumVals.every((v) => typeof v === 'string' || typeof v === 'number')) {
|
|
67
|
+
return { type: 'enum', options: enumVals.map((v) => ({ value: String(v), label: titleCase(String(v)) })) }
|
|
68
|
+
}
|
|
69
|
+
const t = schema['type']
|
|
70
|
+
const fmt = schema['format']
|
|
71
|
+
if (t === 'integer' || t === 'number') return { type: 'number' }
|
|
72
|
+
if (t === 'boolean') return { type: 'boolean' }
|
|
73
|
+
if (t === 'array' || t === 'object') return { type: 'json' }
|
|
74
|
+
if (t === 'string') {
|
|
75
|
+
if (fmt === 'date') return { type: 'dateString' }
|
|
76
|
+
if (fmt === 'date-time') return { type: 'datetime' }
|
|
77
|
+
return { type: 'text' }
|
|
78
|
+
}
|
|
79
|
+
return { type: 'text' }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Object schema -> EntityField[]. `id`/pk detection + required + relations. */
|
|
83
|
+
function schemaToFields(doc: OpenApiDoc, objectSchema: Json, entityByRef: Map<string, string>): EntityField[] {
|
|
84
|
+
const props = (deref(doc, objectSchema)?.['properties'] as Record<string, Json> | undefined) ?? {}
|
|
85
|
+
const required = new Set((deref(doc, objectSchema)?.['required'] as string[] | undefined) ?? [])
|
|
86
|
+
const fields: EntityField[] = []
|
|
87
|
+
for (const [name, rawProp] of Object.entries(props)) {
|
|
88
|
+
const ft = fieldType(doc, rawProp, entityByRef)
|
|
89
|
+
const isPk = name === 'id' || name === '_id'
|
|
90
|
+
const field: EntityField = {
|
|
91
|
+
field: name,
|
|
92
|
+
label: titleCase(name),
|
|
93
|
+
type: ft.type,
|
|
94
|
+
...(isPk ? { primaryKey: true } : {}),
|
|
95
|
+
...(required.has(name) && !isPk ? { required: true } : {}),
|
|
96
|
+
...(ft.options ? { options: ft.options } : {}),
|
|
97
|
+
...(ft.relation ? { relation: { entity: ft.relation, labelField: 'name', foreignKey: name } } : {}),
|
|
98
|
+
}
|
|
99
|
+
fields.push(field)
|
|
100
|
+
}
|
|
101
|
+
return fields
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The item schema for a resource: the 200 response of GET-by-id, else the array
|
|
105
|
+
* item of the list response, else the POST request body. */
|
|
106
|
+
function itemSchemaFor(doc: OpenApiDoc, ops: Record<string, Json>): Json | null {
|
|
107
|
+
const bodySchema = (op: Json | undefined, kind: 'response' | 'request'): Json | null => {
|
|
108
|
+
if (!op) return null
|
|
109
|
+
const content =
|
|
110
|
+
kind === 'response'
|
|
111
|
+
? ((op['responses'] as Json | undefined)?.['200'] as Json | undefined)?.['content']
|
|
112
|
+
: (op['requestBody'] as Json | undefined)?.['content']
|
|
113
|
+
const json = (content as Json | undefined)?.['application/json'] as Json | undefined
|
|
114
|
+
return (json?.['schema'] as Json | undefined) ?? null
|
|
115
|
+
}
|
|
116
|
+
const byId = bodySchema(ops.getOne, 'response')
|
|
117
|
+
if (byId) return deref(doc, byId)
|
|
118
|
+
const list = bodySchema(ops.list, 'response')
|
|
119
|
+
if (list) {
|
|
120
|
+
const s = deref(doc, list)
|
|
121
|
+
if (s?.['type'] === 'array') return deref(doc, s['items'] as Json)
|
|
122
|
+
// wrapped: { data: [ item ] } / { items: [...] } / { results: [...] }
|
|
123
|
+
for (const key of ['data', 'items', 'results', 'rows']) {
|
|
124
|
+
const wrapped = deref(doc, (s?.['properties'] as Record<string, Json> | undefined)?.[key])
|
|
125
|
+
if (wrapped?.['type'] === 'array') return deref(doc, wrapped['items'] as Json)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const created = bodySchema(ops.create, 'request')
|
|
129
|
+
return created ? deref(doc, created) : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `application/json` schema node of an operation's 200 response, else null. */
|
|
133
|
+
function responseSchema(op: Json | undefined): Json | undefined {
|
|
134
|
+
const content = ((op?.['responses'] as Json | undefined)?.['200'] as Json | undefined)?.['content'] as Json | undefined
|
|
135
|
+
return (content?.['application/json'] as Json | undefined)?.['schema'] as Json | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The dotted rows path for a list response ('data'/'items'/...), or undefined
|
|
139
|
+
* when the response is a bare array. */
|
|
140
|
+
function rowsPathFor(doc: OpenApiDoc, listOp: Json | undefined): string | undefined {
|
|
141
|
+
const s = deref(doc, responseSchema(listOp))
|
|
142
|
+
if (!s || s['type'] === 'array') return undefined
|
|
143
|
+
for (const key of ['data', 'items', 'results', 'rows']) {
|
|
144
|
+
if (deref(doc, (s['properties'] as Record<string, Json> | undefined)?.[key])?.['type'] === 'array') return key
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function paramsFor(doc: OpenApiDoc, op: Json | undefined): RequestParam[] {
|
|
150
|
+
const raw = (op?.['parameters'] as Json[] | undefined) ?? []
|
|
151
|
+
const out: RequestParam[] = []
|
|
152
|
+
for (const p of raw) {
|
|
153
|
+
const param = deref(doc, p) ?? p
|
|
154
|
+
const loc = param['in']
|
|
155
|
+
const name = param['name']
|
|
156
|
+
if (typeof name !== 'string' || (loc !== 'query' && loc !== 'path' && loc !== 'header')) continue
|
|
157
|
+
out.push({ name, location: loc, type: 'string' })
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Split "/pets/{petId}" -> { collection: "/pets", isItem: true }. A collection
|
|
163
|
+
* path ends in a static segment; an item path ends in a `{param}`. */
|
|
164
|
+
function classifyPath(path: string): { collection: string; isItem: boolean } {
|
|
165
|
+
const segs = path.split('/').filter(Boolean)
|
|
166
|
+
if (segs.length && /^\{.+\}$/.test(segs.at(-1)!)) return { collection: '/' + segs.slice(0, -1).join('/'), isItem: true }
|
|
167
|
+
return { collection: path, isItem: false }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse + introspect an OpenAPI 3.x JSON document into entities + REST sources. */
|
|
171
|
+
export function introspectOpenApi(specText: string): OpenApiImport {
|
|
172
|
+
let doc: OpenApiDoc
|
|
173
|
+
try {
|
|
174
|
+
doc = JSON.parse(specText) as OpenApiDoc
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error('Could not parse the OpenAPI document. v1 supports JSON only - convert a YAML spec to JSON first (e.g. https://editor.swagger.io export).')
|
|
177
|
+
}
|
|
178
|
+
if (!doc || typeof doc !== 'object' || !doc.paths) throw new Error('Not an OpenAPI document: no "paths" object found.')
|
|
179
|
+
|
|
180
|
+
const warnings: string[] = []
|
|
181
|
+
const baseUrl = (doc.servers?.[0]?.url ?? '').replace(/\/+$/, '')
|
|
182
|
+
|
|
183
|
+
// Group operations by resource (collection path).
|
|
184
|
+
type Res = { name: string; collection: string; ops: Record<string, Json> }
|
|
185
|
+
const byCollection = new Map<string, Res>()
|
|
186
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
187
|
+
const { collection, isItem } = classifyPath(path)
|
|
188
|
+
const segs = collection.split('/').filter(Boolean)
|
|
189
|
+
const last = segs.at(-1)
|
|
190
|
+
if (!last || /^\{.+\}$/.test(last)) { warnings.push(`Skipped path "${path}" (no clear resource name).`); continue }
|
|
191
|
+
const name = singular(last).replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase()
|
|
192
|
+
const res = byCollection.get(collection) ?? { name, collection, ops: {} }
|
|
193
|
+
for (const method of HTTP_METHODS) {
|
|
194
|
+
const op = item[method] as Json | undefined
|
|
195
|
+
if (!op) continue
|
|
196
|
+
if (method === 'get') res.ops[isItem ? 'getOne' : 'list'] = op
|
|
197
|
+
else if (method === 'post' && !isItem) res.ops.create = op
|
|
198
|
+
else if ((method === 'put' || method === 'patch') && isItem) res.ops.update = op
|
|
199
|
+
else if (method === 'delete' && isItem) res.ops.delete = op
|
|
200
|
+
}
|
|
201
|
+
byCollection.set(collection, res)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Two passes: map each resource's component schema NAME -> entity name first,
|
|
205
|
+
// so a property that `$ref`s another resource's schema becomes a relation.
|
|
206
|
+
const resources = [...byCollection.values()].filter((r) => r.ops.list || r.ops.getOne || r.ops.create)
|
|
207
|
+
const entityByRef = new Map<string, string>()
|
|
208
|
+
for (const r of resources) {
|
|
209
|
+
for (const opKey of ['getOne', 'list', 'create'] as const) {
|
|
210
|
+
const op = r.ops[opKey]
|
|
211
|
+
const schema = responseSchema(op) ?? ((((op?.['requestBody'] as Json | undefined)?.['content'] as Json | undefined)?.['application/json'] as Json | undefined)?.['schema'] as Json | undefined)
|
|
212
|
+
const direct = refName(schema)
|
|
213
|
+
if (direct) entityByRef.set(direct, r.name)
|
|
214
|
+
const arrItem = refName(deref(doc, schema)?.['items'] as Json | undefined)
|
|
215
|
+
if (arrItem) entityByRef.set(arrItem, r.name)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const entities: EntitySchema[] = []
|
|
220
|
+
const sources: Record<string, EntityDataSource> = {}
|
|
221
|
+
const seenNames = new Set<string>()
|
|
222
|
+
for (const r of resources) {
|
|
223
|
+
let name = r.name
|
|
224
|
+
while (seenNames.has(name)) name = name + '_'
|
|
225
|
+
seenNames.add(name)
|
|
226
|
+
const item = itemSchemaFor(doc, r.ops)
|
|
227
|
+
if (!item) { warnings.push(`Resource "${r.collection}" has no readable schema - skipped.`); continue }
|
|
228
|
+
const fields = schemaToFields(doc, item, entityByRef)
|
|
229
|
+
if (!fields.length) { warnings.push(`Resource "${r.collection}" resolved no fields - skipped.`); continue }
|
|
230
|
+
if (!fields.some((f) => f.primaryKey)) {
|
|
231
|
+
const idish = fields.find((f) => /id$/i.test(f.field))
|
|
232
|
+
if (idish) idish.primaryKey = true
|
|
233
|
+
else fields.unshift({ field: 'id', label: 'Id', type: 'text', primaryKey: true })
|
|
234
|
+
}
|
|
235
|
+
const idField = fields.find((f) => f.primaryKey)!.field
|
|
236
|
+
entities.push({ name, label: titleCase(name), idField, fields })
|
|
237
|
+
const path = r.collection.replace(/^\/+/, '')
|
|
238
|
+
const source: RestSource = {
|
|
239
|
+
kind: 'rest',
|
|
240
|
+
baseUrl,
|
|
241
|
+
path,
|
|
242
|
+
method: 'GET',
|
|
243
|
+
params: paramsFor(doc, r.ops.list),
|
|
244
|
+
idField,
|
|
245
|
+
...(rowsPathFor(doc, r.ops.list) ? { rowsPath: rowsPathFor(doc, r.ops.list) } : {}),
|
|
246
|
+
}
|
|
247
|
+
sources[name] = source
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!entities.length) throw new Error('No REST resources with a readable schema were found in the document.')
|
|
251
|
+
return { entities, sources, warnings }
|
|
252
|
+
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
addBlock,
|
|
5
5
|
applyGridPreset,
|
|
6
6
|
addStateVar,
|
|
7
|
+
setDataLayer,
|
|
8
|
+
setDeployTarget,
|
|
7
9
|
updateStateVar,
|
|
8
10
|
removeStateVar,
|
|
9
11
|
stateInitExpr,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
reorderScreen,
|
|
56
58
|
insertBlock,
|
|
57
59
|
validateProject,
|
|
60
|
+
setAuth,
|
|
58
61
|
addTab,
|
|
59
62
|
removeTab,
|
|
60
63
|
renameTab,
|
|
@@ -599,6 +602,22 @@ describe('component blocks', () => {
|
|
|
599
602
|
p = addComponentBlock(p, sid, 'button', {})
|
|
600
603
|
expect(validateProject(p).some((i) => /is empty/.test(i.message))).toBe(false)
|
|
601
604
|
})
|
|
605
|
+
|
|
606
|
+
it('Supabase Auth warns without a Supabase connection, and clears once one is set', () => {
|
|
607
|
+
let p = setAuth(createProject([customers]), { enabled: true, provider: 'supabase' })
|
|
608
|
+
const needsConn = (pr: typeof p) => validateProject(pr).some((i) => /Supabase Auth needs a Supabase connection/.test(i.message))
|
|
609
|
+
expect(needsConn(p)).toBe(true)
|
|
610
|
+
// Shared project connection satisfies it.
|
|
611
|
+
p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' } }
|
|
612
|
+
expect(needsConn(p)).toBe(false)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('Supabase Auth + RBAC warns that the client session does not populate the server role', () => {
|
|
616
|
+
let p = createProject([customers])
|
|
617
|
+
p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' }, access: { enabled: true, roles: [] } as never }
|
|
618
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
619
|
+
expect(validateProject(p).some((i) => /Row Level Security/.test(i.message))).toBe(true)
|
|
620
|
+
})
|
|
602
621
|
})
|
|
603
622
|
|
|
604
623
|
describe('validateProject', () => {
|
|
@@ -618,6 +637,33 @@ describe('validateProject', () => {
|
|
|
618
637
|
expect(validateProject(broken).some((i) => /missing entity/.test(i.message))).toBe(true)
|
|
619
638
|
})
|
|
620
639
|
|
|
640
|
+
it('warns when SQL entities have no Drizzle data layer (tables must pre-exist)', () => {
|
|
641
|
+
let p = createProject([customers])
|
|
642
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
643
|
+
const warn = validateProject(p).find((i) => /tables must already exist/.test(i.message))
|
|
644
|
+
expect(warn?.level).toBe('warning')
|
|
645
|
+
// Enabling the Drizzle layer clears it.
|
|
646
|
+
expect(validateProject(setDataLayer(p, true)).some((i) => /tables must already exist/.test(i.message))).toBe(false)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('warns on cloudflare deploy + a socket Postgres driver', () => {
|
|
650
|
+
let p = createProject([customers])
|
|
651
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
652
|
+
p = setDeployTarget(p, 'cloudflare')
|
|
653
|
+
expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(true)
|
|
654
|
+
// sqlite on cloudflare doesn't trip the pg warning.
|
|
655
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'sqlite' })
|
|
656
|
+
expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(false)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('warns when a supabase source has no url/key (stub client)', () => {
|
|
660
|
+
let p = createProject([customers])
|
|
661
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
662
|
+
expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(true)
|
|
663
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon' })
|
|
664
|
+
expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(false)
|
|
665
|
+
})
|
|
666
|
+
|
|
621
667
|
it('the palette lists every block kind', () => {
|
|
622
668
|
expect(blockPalette.map((p) => p.kind)).toContain('grid')
|
|
623
669
|
expect(blockPalette.map((p) => p.kind)).toContain('dashboard')
|
|
@@ -761,6 +807,17 @@ describe('per-entity data sources', () => {
|
|
|
761
807
|
expect(p1.dataSources).toMatchObject({ customers: { kind: 'rest' } })
|
|
762
808
|
})
|
|
763
809
|
|
|
810
|
+
it('an unbound entity inherits the project default source (not always memory)', () => {
|
|
811
|
+
const p0 = createProject([customers]) // dataSource: 'memory'
|
|
812
|
+
expect(entityDataSource(p0, 'customers')).toEqual({ kind: 'memory' })
|
|
813
|
+
// Flip the project default to SQL: unbound entities now resolve to the SQL skeleton.
|
|
814
|
+
const pSql = { ...p0, dataSource: 'sql' as const }
|
|
815
|
+
expect(entityDataSource(pSql, 'customers')).toEqual({ kind: 'sql', table: 'customers' })
|
|
816
|
+
// An explicit per-entity binding still wins over the default.
|
|
817
|
+
const pOverride = setEntityDataSource(pSql, 'customers', { kind: 'memory' })
|
|
818
|
+
expect(entityDataSource(pOverride, 'customers')).toEqual({ kind: 'memory' })
|
|
819
|
+
})
|
|
820
|
+
|
|
764
821
|
it('defaultEntitySource seeds a skeleton per kind', () => {
|
|
765
822
|
expect(defaultEntitySource('rest', 'customers')).toMatchObject({ kind: 'rest', path: 'customers', method: 'GET', params: [] })
|
|
766
823
|
expect(defaultEntitySource('sql', 'orders')).toEqual({ kind: 'sql', table: 'orders' })
|
package/src/studio/project.ts
CHANGED
|
@@ -34,6 +34,24 @@ export type SqlDialectKind = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'supaba
|
|
|
34
34
|
/** In-memory (seeded) source. `seed` carries curated rows (e.g. a sample app);
|
|
35
35
|
* when absent the codegen + preview synthesize realistic rows. */
|
|
36
36
|
export type MemorySource = { kind: 'memory'; seed?: Record<string, unknown>[] }
|
|
37
|
+
/** Wire-format preset that teaches the REST source how a backend pages / sorts /
|
|
38
|
+
* wraps its rows, so the generated grid does REAL server-side paging + sort (not a
|
|
39
|
+
* single fetched page). Maps to the `offsetLimit` / `dummyjson` / `jsonServer`
|
|
40
|
+
* adapters in `@svgrid/enterprise`. Absent = the legacy manual `rowsPath`/`totalPath`
|
|
41
|
+
* (parse only; no server paging). */
|
|
42
|
+
export type RestAdapterConfig =
|
|
43
|
+
| { kind: 'dummyjson'; rowsKey?: string }
|
|
44
|
+
| { kind: 'jsonServer' }
|
|
45
|
+
| {
|
|
46
|
+
kind: 'offsetLimit'
|
|
47
|
+
offsetParam?: string
|
|
48
|
+
limitParam?: string
|
|
49
|
+
sortByParam?: string
|
|
50
|
+
orderParam?: string
|
|
51
|
+
searchParam?: string
|
|
52
|
+
rowsKey?: string
|
|
53
|
+
totalKey?: string
|
|
54
|
+
}
|
|
37
55
|
export type RestSource = {
|
|
38
56
|
kind: 'rest'
|
|
39
57
|
/** Origin + version prefix, e.g. `https://api.example.com/v1`. */
|
|
@@ -43,13 +61,16 @@ export type RestSource = {
|
|
|
43
61
|
method: RestMethod
|
|
44
62
|
params: RequestParam[]
|
|
45
63
|
idField?: string
|
|
64
|
+
/** Wire-format preset for real server paging/sort (offsetLimit / dummyjson /
|
|
65
|
+
* jsonServer). When set, it supersedes `rowsPath`/`totalPath`. */
|
|
66
|
+
adapter?: RestAdapterConfig
|
|
46
67
|
/** Dotted path in the response body holding the rows, e.g. `data.items`. */
|
|
47
68
|
rowsPath?: string
|
|
48
69
|
/** Dotted path holding the total row count, e.g. `data.total`. */
|
|
49
70
|
totalPath?: string
|
|
50
71
|
}
|
|
51
|
-
export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind }
|
|
52
|
-
export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string }
|
|
72
|
+
export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind; /** DB schema / search path (Postgres/MSSQL); default `public`. Qualifies the table. */ schema?: string }
|
|
73
|
+
export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string; realtime?: boolean }
|
|
53
74
|
/** Embedded Postgres (PGlite) - a real, persistent database with ZERO backend
|
|
54
75
|
* setup. Runs in the browser, persisting to IndexedDB. Swap for a `sql` source
|
|
55
76
|
* pointed at hosted Postgres to go to production (same schema + SQL). */
|
|
@@ -100,6 +121,15 @@ export type GridConfig = {
|
|
|
100
121
|
editing: GridEditing
|
|
101
122
|
/** Presentation of the edit form when `editing === 'form'`. */
|
|
102
123
|
formPresentation: Presentation
|
|
124
|
+
/** Form layout depth (all optional; default = the auto 2-column form of all fields). */
|
|
125
|
+
formColumns?: 1 | 2 | 3
|
|
126
|
+
/** Restrict + order the form's fields (field names). */
|
|
127
|
+
formFields?: string[]
|
|
128
|
+
/** Group fields into titled fieldsets. */
|
|
129
|
+
formSections?: Array<{ title?: string; fields: string[] }>
|
|
130
|
+
/** Dialog title override + width for the modal/drawer form. */
|
|
131
|
+
formTitle?: string
|
|
132
|
+
formSize?: 'sm' | 'md' | 'lg'
|
|
103
133
|
density: GridDensity
|
|
104
134
|
striped: boolean
|
|
105
135
|
/** Excel-style cell/range selection. */
|
|
@@ -135,6 +165,10 @@ export type GridConfig = {
|
|
|
135
165
|
* Kanban board). Mutually exclusive with grouping / tree. Uses the enterprise
|
|
136
166
|
* scheduler renderer (`enableSchedulerView`). */
|
|
137
167
|
scheduler?: SchedulerViewConfig
|
|
168
|
+
/** Raw SvGrid prop overrides from the block's "All properties" panel - every grid
|
|
169
|
+
* prop the curated controls don't manage. Passed straight through to `<SvGrid>`
|
|
170
|
+
* (deduped against the curated props at codegen time). */
|
|
171
|
+
props?: Record<string, unknown>
|
|
138
172
|
}
|
|
139
173
|
/** Which calendar view the scheduler opens on / offers. */
|
|
140
174
|
export type SchedulerViewMode = 'month' | 'week' | 'day' | 'agenda' | 'timelineDay' | 'timelineWeek' | 'timelineMonth' | 'timelineYear'
|
|
@@ -360,7 +394,9 @@ export function blockClassName(block: Pick<Block, 'className'>): string {
|
|
|
360
394
|
}
|
|
361
395
|
/** The number of columns (1-12) a block occupies in the 12-col layout. */
|
|
362
396
|
export const blockColumns = (b: Pick<Block, 'span' | 'colSpan'>): number =>
|
|
363
|
-
|
|
397
|
+
// Default to full width when a block sets neither colSpan nor span (Copilot /
|
|
398
|
+
// hand-authored configs may omit both) - never emit `span NaN` into the HTML.
|
|
399
|
+
Math.max(1, Math.min(12, Math.round(b.colSpan ?? (b.span != null ? b.span * 4 : 12))))
|
|
364
400
|
|
|
365
401
|
/** Restrict a user-typed color to a safe subset so it can't break out of an inline
|
|
366
402
|
* `style="..."` attribute (hex, rgb()/hsl(), named colors, css vars). */
|
|
@@ -408,10 +444,49 @@ export const clickSlot = (blockId: string) => `click:${blockId}`
|
|
|
408
444
|
export const rowSelectSlot = (blockId: string) => `rowSelect:${blockId}`
|
|
409
445
|
/** The handler-steps key for a component block's change (value change) event. */
|
|
410
446
|
export const changeSlot = (blockId: string) => `change:${blockId}`
|
|
447
|
+
/** Generic event slot - `click`/`change` produce the same keys as clickSlot/changeSlot,
|
|
448
|
+
* so existing projects keep their wiring; any other declared component event
|
|
449
|
+
* (focus, select, toggle, ...) gets its own `<event>:<blockId>` slot. */
|
|
450
|
+
export const eventSlot = (event: string, blockId: string) => `${event}:${blockId}`
|
|
411
451
|
/** The handler-steps key for the screen's form-submit (record saved) event. The
|
|
412
452
|
* compiled steps get the submitted `row` (values) in scope. */
|
|
413
453
|
export const FORM_SUBMIT = 'formSubmit'
|
|
414
454
|
|
|
455
|
+
/** One event on the Grid, exposed to code-behind as `ctx.grid.<method> = (e) => {}`.
|
|
456
|
+
* `prop` is the SvGrid callback it wires to (empty for `dataEvent`s, which fire from
|
|
457
|
+
* Studio's data controller on create/update/delete rather than from the grid markup). */
|
|
458
|
+
export type GridEventDef = { key: string; method: string; prop: string; params: string; builtin: boolean; desc: string; dataEvent?: boolean }
|
|
459
|
+
/** The Grid's real event surface (mirrors SvGrid's `on*` callbacks). Studio wires
|
|
460
|
+
* every one into `ctx.grid` so page code can subscribe with `ctx.grid.onCellClick =
|
|
461
|
+
* (e) => {}`. `params` is the handler's parameter list, with `Row` as the row-type
|
|
462
|
+
* placeholder (substituted per screen). `builtin` marks events a Studio feature may
|
|
463
|
+
* already use (sort/filter/paginate/edit/row-click) - codegen COMPOSES the user's
|
|
464
|
+
* handler onto the built-in rather than emitting a duplicate prop. Signatures are
|
|
465
|
+
* transcribed from packages/grid/src/SvGrid.types.ts. */
|
|
466
|
+
export const GRID_EVENTS: readonly GridEventDef[] = [
|
|
467
|
+
{ key: 'rowClick', method: 'onRowClick', prop: 'onRowClick', builtin: true, desc: 'A data row is single-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
|
|
468
|
+
{ key: 'rowDoubleClick', method: 'onRowDoubleClick', prop: 'onRowDoubleClick', builtin: true, desc: 'A data row is double-clicked.', params: 'e: { rowIndex: number; columnId: string; row: Row }' },
|
|
469
|
+
{ key: 'cellClick', method: 'onCellClick', prop: 'onCellClick', builtin: false, desc: 'A data cell is single-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
|
|
470
|
+
{ key: 'cellDoubleClick', method: 'onCellDoubleClick', prop: 'onCellDoubleClick', builtin: false, desc: 'A data cell is double-clicked.', params: 'e: { rowIndex: number; colIndex: number; columnId: string; value: unknown; row: Row }' },
|
|
471
|
+
{ key: 'rowSelectionChange', method: 'onRowSelectionChange', prop: 'onRowSelectionChange', builtin: false, desc: 'The row selection changes.', params: 'selection: Record<string, boolean>, rows: Row[]' },
|
|
472
|
+
{ key: 'cellSelectionChange', method: 'onCellSelectionChange', prop: 'onCellSelectionChange', builtin: false, desc: 'The cell-selection rectangle changes.', params: 'ranges: Array<[number, number, number, number]>' },
|
|
473
|
+
{ key: 'activeCellChange', method: 'onActiveCellChange', prop: 'onActiveCellChange', builtin: false, desc: 'The active cell changes.', params: 'cell: { rowIndex: number; colIndex: number; columnId: string }' },
|
|
474
|
+
{ key: 'cellValueChange', method: 'onCellValueChange', prop: 'onCellValueChange', builtin: true, desc: 'An inline cell edit is committed.', params: 'e: { rowIndex: number; columnId: string; oldValue: unknown; newValue: unknown; row: Row }' },
|
|
475
|
+
{ key: 'sortingChange', method: 'onSortingChange', prop: 'onSortingChange', builtin: true, desc: 'The sort clauses change.', params: 'sorting: Array<{ id: string; desc: boolean }>' },
|
|
476
|
+
{ key: 'filtersChange', method: 'onFiltersChange', prop: 'onFiltersChange', builtin: true, desc: 'Any in-grid filter changes.', params: 'filters: { global: string; columns: Array<{ id: string; operator: string; value: string; valueTo?: string; selectedValues?: string[] }> }' },
|
|
477
|
+
{ key: 'paginationChange', method: 'onPaginationChange', prop: 'onPaginationChange', builtin: true, desc: 'The page or page size changes.', params: 'pagination: { pageIndex: number; pageSize: number }' },
|
|
478
|
+
{ key: 'columnOrderChange', method: 'onColumnOrderChange', prop: 'onColumnOrderChange', builtin: false, desc: 'The column order changes.', params: 'order: ReadonlyArray<string>' },
|
|
479
|
+
{ key: 'scrollBottomReached', method: 'onScrollBottomReached', prop: 'onScrollBottomReached', builtin: false, desc: 'The body scrolls near the bottom (lazy-load hook).', params: 'e: { scrollTop: number; scrollHeight: number; clientHeight: number }' },
|
|
480
|
+
{ key: 'noteChange', method: 'onNoteChange', prop: 'onNoteChange', builtin: false, desc: 'A cell note/comment is saved or removed (needs editable comments).', params: 'e: { rowId: string; columnId: string; note: string }' },
|
|
481
|
+
{ key: 'rowDragEnd', method: 'onRowDragEnd', prop: 'onRowDragEnd', builtin: false, desc: 'A managed row drag settles (needs row dragging enabled).', params: 'e: { row: Row; toIndex: number; sameGrid: boolean; fromGridId: number; toGridId: number }' },
|
|
482
|
+
// Data-layer events - fired by the screen's data controller after a write settles
|
|
483
|
+
// (form save, inline edit, delete action, or ctx.data.create/update/delete), not by
|
|
484
|
+
// the grid markup. Only fire on entity screens (which have a data source).
|
|
485
|
+
{ key: 'rowAdded', method: 'onRowAdded', prop: '', builtin: false, dataEvent: true, desc: 'A row was created (form / ctx.data.create).', params: 'row: Row' },
|
|
486
|
+
{ key: 'rowUpdated', method: 'onRowUpdated', prop: '', builtin: false, dataEvent: true, desc: 'A row was updated (form / inline edit / ctx.data.update).', params: 'row: Row' },
|
|
487
|
+
{ key: 'rowDeleted', method: 'onRowDeleted', prop: '', builtin: false, dataEvent: true, desc: 'A row was deleted (delete action / ctx.data.delete).', params: 'id: string' },
|
|
488
|
+
]
|
|
489
|
+
|
|
415
490
|
// --- logic core: screen state + a small expression engine --------------------
|
|
416
491
|
|
|
417
492
|
/** A screen-scoped reactive variable (`ctx.state.<name>`). Emitted as `$state`. */
|
|
@@ -644,7 +719,7 @@ export function setLayoutOpts<K extends keyof LayoutOpts>(project: StudioProject
|
|
|
644
719
|
return mapScreen(project, screenId, (s) => ({ ...s, layoutOpts: { ...s.layoutOpts, [mode]: { ...s.layoutOpts?.[mode], ...patch } } }))
|
|
645
720
|
}
|
|
646
721
|
|
|
647
|
-
export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[] }
|
|
722
|
+
export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[]; renderMode?: 'ssr' | 'spa' }
|
|
648
723
|
|
|
649
724
|
/** The generated app's shell (master layout): sidebar, top-nav, or bottom-nav; brand, footer. */
|
|
650
725
|
export type ShellStyle = 'sidebar' | 'top-nav' | 'bottom-nav'
|
|
@@ -680,6 +755,13 @@ export type AccessControl = {
|
|
|
680
755
|
export type OAuthProvider = 'github' | 'google' | 'oidc'
|
|
681
756
|
export type AuthConfig = {
|
|
682
757
|
enabled: boolean
|
|
758
|
+
/** Which sign-in system to scaffold. `'builtin'` (default) is the dependency-free
|
|
759
|
+
* cookie-session starter below (own user store, server route guards, RBAC).
|
|
760
|
+
* `'supabase'` delegates to Supabase Auth: a client-side `SvAuthGate` over the
|
|
761
|
+
* shared Supabase client, pairing with the database's Row Level Security. The
|
|
762
|
+
* builtin-only sub-options (register / userAdmin / oauth / twoFactor / email) do
|
|
763
|
+
* not apply to the Supabase provider. */
|
|
764
|
+
provider?: 'builtin' | 'supabase'
|
|
683
765
|
/** Redirect unauthenticated visitors to /login for every route. Default true. */
|
|
684
766
|
protect?: boolean
|
|
685
767
|
/** Self-service sign-up + password recovery (/register, /forgot-password,
|
|
@@ -711,6 +793,10 @@ export type StudioProject = {
|
|
|
711
793
|
dataSource: DataSourceKind
|
|
712
794
|
/** Per-entity data-source binding, keyed by entity name. */
|
|
713
795
|
dataSources?: Record<string, EntityDataSource>
|
|
796
|
+
/** Project-level Supabase connection (URL + anon key), shared by every
|
|
797
|
+
* Supabase-bound entity so it's set once. A per-entity `SupabaseSource.url`/`key`
|
|
798
|
+
* still overrides for that entity. Read by the wizard + the `.env` paste hint. */
|
|
799
|
+
supabase?: { url?: string; key?: string }
|
|
714
800
|
theme?: ProjectTheme
|
|
715
801
|
/** Role-based access control (optional; off unless `access.enabled`). */
|
|
716
802
|
access?: AccessControl
|
|
@@ -733,6 +819,55 @@ export type StudioProject = {
|
|
|
733
819
|
triggers?: Record<string, EntityTriggers>
|
|
734
820
|
}
|
|
735
821
|
|
|
822
|
+
/** The render mode for a screen. `'ssr'` emits idiomatic SvelteKit (`+page.server.ts`
|
|
823
|
+
* with a `load` + form `actions`, SSR + progressive enhancement); `'spa'` (the
|
|
824
|
+
* default) emits the client data-source-controller page. Opt-in for now. */
|
|
825
|
+
export function screenRenderMode(_project: StudioProject, screen: Screen): 'ssr' | 'spa' {
|
|
826
|
+
return screen.renderMode === 'ssr' ? 'ssr' : 'spa'
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Block kinds a read-only SSR screen can host: pure renders over server-loaded
|
|
830
|
+
* rows. Board/calendar/scheduler (client interaction runtimes), components
|
|
831
|
+
* (client bindings), grids-with-extras, and containers stay SPA. */
|
|
832
|
+
const SSR_READ_KINDS = new Set<BlockKind>(['chart', 'pivot', 'dashboard', 'kpi', 'gauge', 'tree', 'detail', 'master-detail'])
|
|
833
|
+
|
|
834
|
+
/** How a screen would emit under SSR: 'grid' (single grid -> load + form actions,
|
|
835
|
+
* URL-driven sort/filter/page), 'read' (data-viz/detail blocks -> load only), or
|
|
836
|
+
* null when it must stay SPA. */
|
|
837
|
+
export function ssrScreenShape(project: StudioProject, screen: Screen): 'grid' | 'read' | null {
|
|
838
|
+
if (!screen.entity || screen.code) return null
|
|
839
|
+
const kind = project.dataSources?.[screen.entity]?.kind ?? project.dataSource
|
|
840
|
+
// memory runs the source in-process; sql reuses the connected /api route via
|
|
841
|
+
// event.fetch. (rest/supabase/pglite stay SPA for now.)
|
|
842
|
+
if (kind !== 'memory' && kind !== 'sql') return null
|
|
843
|
+
const blocks = screen.blocks ?? []
|
|
844
|
+
if (blocks.length === 1 && blocks[0]!.config.kind === 'grid') {
|
|
845
|
+
const g = blocks[0]!.config as GridConfig
|
|
846
|
+
if (g.treeData || g.scheduler) return null
|
|
847
|
+
return 'grid'
|
|
848
|
+
}
|
|
849
|
+
if (blocks.length >= 1 && blocks.every((b) => SSR_READ_KINDS.has(b.config.kind))) return 'read'
|
|
850
|
+
return null
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Whether a screen can be emitted as SSR-native (any supported shape). */
|
|
854
|
+
export function ssrEligible(project: StudioProject, screen: Screen): boolean {
|
|
855
|
+
return ssrScreenShape(project, screen) !== null
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Set a screen's render mode ('spa' clears back to the default). */
|
|
859
|
+
export function setScreenRenderMode(project: StudioProject, screenId: string, mode: 'ssr' | 'spa'): StudioProject {
|
|
860
|
+
return {
|
|
861
|
+
...project,
|
|
862
|
+
screens: project.screens.map((s) => (s.id === screenId ? { ...s, renderMode: mode === 'ssr' ? 'ssr' : undefined } : s)),
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** True when the screen should actually emit as SSR (mode is 'ssr' AND eligible). */
|
|
867
|
+
export function isSsrScreen(project: StudioProject, screen: Screen): boolean {
|
|
868
|
+
return screenRenderMode(project, screen) === 'ssr' && ssrEligible(project, screen)
|
|
869
|
+
}
|
|
870
|
+
|
|
736
871
|
/** The triggers configured for an entity (or an empty object). */
|
|
737
872
|
export function triggersOf(project: StudioProject, entity: string): EntityTriggers {
|
|
738
873
|
return project.triggers?.[entity] ?? {}
|
|
@@ -1761,10 +1896,12 @@ export function setAuth(project: StudioProject, patch: Partial<AuthConfig> & { e
|
|
|
1761
1896
|
if (!patch.enabled) { const { auth: _drop, ...rest } = project; return rest }
|
|
1762
1897
|
const prev = project.auth
|
|
1763
1898
|
const oauth = patch.oauth ?? prev?.oauth
|
|
1899
|
+
const provider = patch.provider ?? prev?.provider
|
|
1764
1900
|
return {
|
|
1765
1901
|
...project,
|
|
1766
1902
|
auth: {
|
|
1767
1903
|
enabled: true,
|
|
1904
|
+
...(provider && provider !== 'builtin' ? { provider } : {}),
|
|
1768
1905
|
protect: patch.protect ?? prev?.protect ?? true,
|
|
1769
1906
|
register: patch.register ?? prev?.register ?? false,
|
|
1770
1907
|
userAdmin: patch.userAdmin ?? prev?.userAdmin ?? false,
|
|
@@ -1812,9 +1949,11 @@ export function setEntityDataSource(project: StudioProject, entityName: string,
|
|
|
1812
1949
|
return { ...project, dataSources: { ...project.dataSources, [entityName]: source } }
|
|
1813
1950
|
}
|
|
1814
1951
|
|
|
1815
|
-
/** The resolved source for an entity
|
|
1952
|
+
/** The resolved source for an entity: its explicit per-entity binding, else a skeleton
|
|
1953
|
+
* for the project's default source kind (so the rail "Default source" actually applies
|
|
1954
|
+
* to unbound entities, and this agrees with `ssrScreenShape`'s fallback). */
|
|
1816
1955
|
export function entityDataSource(project: StudioProject, entityName: string): EntityDataSource {
|
|
1817
|
-
return project.dataSources?.[entityName] ??
|
|
1956
|
+
return project.dataSources?.[entityName] ?? defaultEntitySource(project.dataSource ?? 'memory', entityName)
|
|
1818
1957
|
}
|
|
1819
1958
|
|
|
1820
1959
|
export function setTheme(project: StudioProject, theme: ProjectTheme): StudioProject {
|
|
@@ -1972,6 +2111,7 @@ export function parseProject(json: string): StudioProject {
|
|
|
1972
2111
|
screens: p.screens as Screen[],
|
|
1973
2112
|
dataSource: (p.dataSource ?? 'memory') as DataSourceKind,
|
|
1974
2113
|
...(p.dataSources && typeof p.dataSources === 'object' ? { dataSources: p.dataSources as Record<string, EntityDataSource> } : {}),
|
|
2114
|
+
...(p.supabase && typeof p.supabase === 'object' ? { supabase: p.supabase as { url?: string; key?: string } } : {}),
|
|
1975
2115
|
...(p.theme && typeof p.theme === 'object' ? { theme: p.theme as ProjectTheme } : {}),
|
|
1976
2116
|
...(p.access && typeof p.access === 'object' ? { access: p.access as AccessControl } : {}),
|
|
1977
2117
|
...(p.auth && typeof p.auth === 'object' && (p.auth as AuthConfig).enabled ? { auth: p.auth as AuthConfig } : {}),
|
|
@@ -2028,6 +2168,53 @@ export function validateProject(project: StudioProject): ProjectIssue[] {
|
|
|
2028
2168
|
}
|
|
2029
2169
|
}
|
|
2030
2170
|
}
|
|
2171
|
+
|
|
2172
|
+
// Project-level deployment footguns (all advisory - the app still generates).
|
|
2173
|
+
const sources = Object.values(project.dataSources ?? {})
|
|
2174
|
+
const sqlSources = sources.filter((s): s is Extract<EntityDataSource, { kind: 'sql' }> => s.kind === 'sql')
|
|
2175
|
+
if (sqlSources.length > 0 && project.dataLayer !== 'drizzle') {
|
|
2176
|
+
issues.push({
|
|
2177
|
+
level: 'warning',
|
|
2178
|
+
message:
|
|
2179
|
+
'SQL entities have no migrations - their tables must already exist in the database. ' +
|
|
2180
|
+
'Enable the Drizzle data layer for typed migrations, or run the generated db/schema.sql against your database first.',
|
|
2181
|
+
})
|
|
2182
|
+
}
|
|
2183
|
+
if (project.deploy === 'cloudflare' && sqlSources.some((s) => s.dialect === 'postgres' || s.dialect === 'supabase')) {
|
|
2184
|
+
issues.push({
|
|
2185
|
+
level: 'warning',
|
|
2186
|
+
message:
|
|
2187
|
+
'Cloudflare Workers cannot run the socket "pg" Postgres driver. Swap the generated route to an HTTP driver ' +
|
|
2188
|
+
'(e.g. Neon serverless) or choose another deploy target.',
|
|
2189
|
+
})
|
|
2190
|
+
}
|
|
2191
|
+
for (const [name, src] of Object.entries(project.dataSources ?? {})) {
|
|
2192
|
+
if (src.kind === 'supabase' && !(src.url ?? project.supabase?.url) && !(src.key ?? project.supabase?.key)) {
|
|
2193
|
+
issues.push({
|
|
2194
|
+
level: 'warning',
|
|
2195
|
+
message: `Supabase entity "${name}" has no URL/key - set the shared project connection (or this entity's own), or the generated app just reads PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY from .env.`,
|
|
2196
|
+
})
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
// Supabase Auth needs a Supabase client to authenticate against: either the shared
|
|
2200
|
+
// project connection or at least one Supabase-bound entity (both emit connections.ts).
|
|
2201
|
+
if (project.auth?.enabled && project.auth.provider === 'supabase') {
|
|
2202
|
+
const hasSupabase = !!project.supabase?.url || sources.some((s) => s.kind === 'supabase')
|
|
2203
|
+
if (!hasSupabase) {
|
|
2204
|
+
issues.push({
|
|
2205
|
+
level: 'warning',
|
|
2206
|
+
message: 'Supabase Auth needs a Supabase connection. Set the shared project URL / anon key (in a data-source builder), or bind an entity to Supabase.',
|
|
2207
|
+
})
|
|
2208
|
+
}
|
|
2209
|
+
// Supabase Auth is a client-side gate; it does not populate the server-side role
|
|
2210
|
+
// the RBAC route guard reads. Server enforcement must come from RLS instead.
|
|
2211
|
+
if (project.access?.enabled) {
|
|
2212
|
+
issues.push({
|
|
2213
|
+
level: 'warning',
|
|
2214
|
+
message: 'Supabase Auth signs in on the client, so it does not populate the server-side role that RBAC route guards check. Enforce per-user access with Row Level Security policies on your Supabase tables.',
|
|
2215
|
+
})
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2031
2218
|
return issues
|
|
2032
2219
|
}
|
|
2033
2220
|
|