@svgrid/enterprise 2.2.1 → 2.3.1

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 (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { introspectOpenApi } from './introspect-openapi'
3
+
4
+ // A petstore-ish spec: bare-array list, $ref item schema, an enum, a nested $ref
5
+ // relation (pet.category -> Category), and a wrapped-list resource (orders).
6
+ const spec = {
7
+ openapi: '3.0.3',
8
+ servers: [{ url: 'https://api.example.com/v1/' }],
9
+ paths: {
10
+ '/pets': {
11
+ get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } } } } } }, parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }] },
12
+ post: { requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } },
13
+ },
14
+ '/pets/{petId}': {
15
+ get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } } },
16
+ put: { responses: { '200': {} } },
17
+ delete: { responses: { '204': {} } },
18
+ },
19
+ '/categories': { get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Category' } } } } } } } },
20
+ '/orders': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderList' } } } } } } },
21
+ },
22
+ components: {
23
+ schemas: {
24
+ Pet: {
25
+ type: 'object',
26
+ required: ['name'],
27
+ properties: {
28
+ id: { type: 'integer' },
29
+ name: { type: 'string' },
30
+ status: { type: 'string', enum: ['available', 'pending', 'sold'] },
31
+ bornOn: { type: 'string', format: 'date' },
32
+ category: { $ref: '#/components/schemas/Category' },
33
+ tags: { type: 'array', items: { type: 'string' } },
34
+ },
35
+ },
36
+ Category: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } },
37
+ Order: { type: 'object', properties: { id: { type: 'integer' }, total: { type: 'number' }, placedAt: { type: 'string', format: 'date-time' } } },
38
+ OrderList: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Order' } }, total: { type: 'integer' } } },
39
+ },
40
+ },
41
+ }
42
+
43
+ describe('introspectOpenApi', () => {
44
+ const out = introspectOpenApi(JSON.stringify(spec))
45
+
46
+ it('groups paths into one entity + one REST source per resource', () => {
47
+ expect(out.entities.map((e) => e.name).sort()).toEqual(['category', 'order', 'pet'])
48
+ const pet = out.sources.pet
49
+ expect(pet).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com/v1', path: 'pets', method: 'GET', idField: 'id' })
50
+ // query parameters carried onto the source
51
+ if (pet?.kind === 'rest') expect(pet.params).toEqual([{ name: 'status', location: 'query', type: 'string' }])
52
+ })
53
+
54
+ it('maps JSON-Schema types + formats + enums + pk', () => {
55
+ const pet = out.entities.find((e) => e.name === 'pet')!
56
+ const byName = new Map(pet.fields.map((f) => [f.field, f]))
57
+ expect(byName.get('id')).toMatchObject({ type: 'number', primaryKey: true })
58
+ expect(byName.get('name')).toMatchObject({ type: 'text', required: true })
59
+ expect(byName.get('status')).toMatchObject({ type: 'enum' })
60
+ expect(byName.get('status')!.options!.map((o) => o.value)).toEqual(['available', 'pending', 'sold'])
61
+ expect(byName.get('bornOn')!.type).toBe('dateString')
62
+ expect(byName.get('tags')!.type).toBe('json')
63
+ })
64
+
65
+ it('turns a $ref property into a relation to the referenced entity', () => {
66
+ const pet = out.entities.find((e) => e.name === 'pet')!
67
+ const category = pet.fields.find((f) => f.field === 'category')!
68
+ expect(category).toMatchObject({ type: 'relation', relation: { entity: 'category', foreignKey: 'category' } })
69
+ })
70
+
71
+ it('detects a wrapped list response (rowsPath = data) + item schema behind it', () => {
72
+ const src = out.sources.order
73
+ expect(src?.kind === 'rest' ? src.rowsPath : null).toBe('data')
74
+ const order = out.entities.find((e) => e.name === 'order')!
75
+ expect(order.fields.map((f) => f.field).sort()).toEqual(['id', 'placedAt', 'total'])
76
+ expect(order.fields.find((f) => f.field === 'placedAt')!.type).toBe('datetime')
77
+ })
78
+
79
+ it('rejects non-JSON and non-OpenAPI input with clear errors', () => {
80
+ expect(() => introspectOpenApi('name: swagger')).toThrow(/JSON only/)
81
+ expect(() => introspectOpenApi('{"foo":1}')).toThrow(/no "paths"/)
82
+ expect(() => introspectOpenApi('{"paths":{}}')).toThrow(/No REST resources/)
83
+ })
84
+ })
@@ -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,
@@ -37,7 +39,6 @@ import {
37
39
  blockPalette,
38
40
  createProject,
39
41
  defaultBlockConfig,
40
- defaultScreenFor,
41
42
  gridColumns,
42
43
  moveBlock,
43
44
  removeBlock,
@@ -55,6 +56,7 @@ import {
55
56
  reorderScreen,
56
57
  insertBlock,
57
58
  validateProject,
59
+ setAuth,
58
60
  addTab,
59
61
  removeTab,
60
62
  renameTab,
@@ -599,6 +601,22 @@ describe('component blocks', () => {
599
601
  p = addComponentBlock(p, sid, 'button', {})
600
602
  expect(validateProject(p).some((i) => /is empty/.test(i.message))).toBe(false)
601
603
  })
604
+
605
+ it('Supabase Auth warns without a Supabase connection, and clears once one is set', () => {
606
+ let p = setAuth(createProject([customers]), { enabled: true, provider: 'supabase' })
607
+ const needsConn = (pr: typeof p) => validateProject(pr).some((i) => /Supabase Auth needs a Supabase connection/.test(i.message))
608
+ expect(needsConn(p)).toBe(true)
609
+ // Shared project connection satisfies it.
610
+ p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' } }
611
+ expect(needsConn(p)).toBe(false)
612
+ })
613
+
614
+ it('Supabase Auth + RBAC warns that the client session does not populate the server role', () => {
615
+ let p = createProject([customers])
616
+ p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' }, access: { enabled: true, roles: [] } as never }
617
+ p = setAuth(p, { enabled: true, provider: 'supabase' })
618
+ expect(validateProject(p).some((i) => /Row Level Security/.test(i.message))).toBe(true)
619
+ })
602
620
  })
603
621
 
604
622
  describe('validateProject', () => {
@@ -618,6 +636,33 @@ describe('validateProject', () => {
618
636
  expect(validateProject(broken).some((i) => /missing entity/.test(i.message))).toBe(true)
619
637
  })
620
638
 
639
+ it('warns when SQL entities have no Drizzle data layer (tables must pre-exist)', () => {
640
+ let p = createProject([customers])
641
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
642
+ const warn = validateProject(p).find((i) => /tables must already exist/.test(i.message))
643
+ expect(warn?.level).toBe('warning')
644
+ // Enabling the Drizzle layer clears it.
645
+ expect(validateProject(setDataLayer(p, true)).some((i) => /tables must already exist/.test(i.message))).toBe(false)
646
+ })
647
+
648
+ it('warns on cloudflare deploy + a socket Postgres driver', () => {
649
+ let p = createProject([customers])
650
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
651
+ p = setDeployTarget(p, 'cloudflare')
652
+ expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(true)
653
+ // sqlite on cloudflare doesn't trip the pg warning.
654
+ p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'sqlite' })
655
+ expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(false)
656
+ })
657
+
658
+ it('warns when a supabase source has no url/key (stub client)', () => {
659
+ let p = createProject([customers])
660
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
661
+ expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(true)
662
+ p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon' })
663
+ expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(false)
664
+ })
665
+
621
666
  it('the palette lists every block kind', () => {
622
667
  expect(blockPalette.map((p) => p.kind)).toContain('grid')
623
668
  expect(blockPalette.map((p) => p.kind)).toContain('dashboard')
@@ -761,6 +806,17 @@ describe('per-entity data sources', () => {
761
806
  expect(p1.dataSources).toMatchObject({ customers: { kind: 'rest' } })
762
807
  })
763
808
 
809
+ it('an unbound entity inherits the project default source (not always memory)', () => {
810
+ const p0 = createProject([customers]) // dataSource: 'memory'
811
+ expect(entityDataSource(p0, 'customers')).toEqual({ kind: 'memory' })
812
+ // Flip the project default to SQL: unbound entities now resolve to the SQL skeleton.
813
+ const pSql = { ...p0, dataSource: 'sql' as const }
814
+ expect(entityDataSource(pSql, 'customers')).toEqual({ kind: 'sql', table: 'customers' })
815
+ // An explicit per-entity binding still wins over the default.
816
+ const pOverride = setEntityDataSource(pSql, 'customers', { kind: 'memory' })
817
+ expect(entityDataSource(pOverride, 'customers')).toEqual({ kind: 'memory' })
818
+ })
819
+
764
820
  it('defaultEntitySource seeds a skeleton per kind', () => {
765
821
  expect(defaultEntitySource('rest', 'customers')).toMatchObject({ kind: 'rest', path: 'customers', method: 'GET', params: [] })
766
822
  expect(defaultEntitySource('sql', 'orders')).toEqual({ kind: 'sql', table: 'orders' })