@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.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22468 -10459
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +6 -0
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. 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' })
@@ -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
- Math.max(1, Math.min(12, Math.round(b.colSpan ?? b.span * 4)))
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 (its explicit binding, else in-memory). */
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] ?? { kind: 'memory' }
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