@svgrid/enterprise 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
package/src/schema.ts ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * EntitySchema - the single declarative model that both the grid columns and
3
+ * the (commercial) edit panel / server adapters / Studio generator read from.
4
+ *
5
+ * The grid already has `ColumnDef` for *rendering* a table. An EntitySchema is
6
+ * one level up: it describes an *entity* (a table row's fields, their types,
7
+ * validation, keys, and relations) so the same definition can drive:
8
+ *
9
+ * - the grid (via `schemaToColumns`)
10
+ * - the edit panel (via `schemaToFormFields`)
11
+ * - server adapters (Drizzle / Supabase, later phases)
12
+ * - the Studio generator (introspect a data source -> EntitySchema -> code)
13
+ *
14
+ * It deliberately reuses the grid's own vocabulary - `CellDataType`,
15
+ * `CellEditorType`, `CellEditorOption`, `ColumnDef` - so a schema is a
16
+ * *superset* of what a column needs, never a parallel model. Anything the
17
+ * grid can already express is reachable through the per-field `column`
18
+ * escape hatch.
19
+ *
20
+ * Validation is typed against the Standard Schema spec (standardschema.dev),
21
+ * so Zod / Valibot / ArkType all satisfy `EntityField.validate` with zero
22
+ * hard dependency here - the same BYO approach as `setAIProvider()`.
23
+ */
24
+ import type {
25
+ CellDataType,
26
+ CellEditorOption,
27
+ CellEditorType,
28
+ ColumnDef,
29
+ RowData,
30
+ TableFeatures,
31
+ } from '@svgrid/grid'
32
+
33
+ /**
34
+ * Minimal structural copy of the Standard Schema v1 interface
35
+ * (https://standardschema.dev). We type against the shape rather than
36
+ * depend on `@standard-schema/spec` so consumers can bring Zod, Valibot,
37
+ * ArkType, or a hand-rolled validator interchangeably.
38
+ */
39
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
40
+ readonly '~standard': {
41
+ readonly version: 1
42
+ readonly vendor: string
43
+ readonly validate: (
44
+ value: unknown,
45
+ ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>
46
+ readonly types?: { readonly input: Input; readonly output: Output }
47
+ }
48
+ }
49
+
50
+ export type StandardSchemaResult<Output> =
51
+ | { readonly value: Output; readonly issues?: undefined }
52
+ | { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray<PropertyKey | { key: PropertyKey }> }> }
53
+
54
+ /**
55
+ * High-level field type. A superset of the grid's `CellDataType`: the first
56
+ * five map 1:1 onto it, and `datetime` / `enum` / `relation` / `json` extend
57
+ * it for entity concerns the grid's render-only type never needed.
58
+ */
59
+ export type EntityFieldType =
60
+ | CellDataType // 'text' | 'number' | 'boolean' | 'date' | 'dateString'
61
+ | 'datetime'
62
+ | 'enum'
63
+ | 'relation'
64
+ | 'json'
65
+
66
+ /**
67
+ * The control a form field renders as. A superset of the grid's `CellEditorType`:
68
+ * the grid cell editors (which double as form controls) plus a few form-only rich
69
+ * inputs from the editor suite - `phone` (SvPhoneInput), `country` (SvCountryInput),
70
+ * `mask` (SvMaskedInput), `slider` (SvSlider). Grid columns map the form-only kinds
71
+ * back to a safe cell editor (see `schemaToColumns`).
72
+ */
73
+ export type StudioEditorType = CellEditorType | 'phone' | 'country' | 'mask' | 'slider'
74
+
75
+ /** Describe one field of an entity. Keys off the row property `field`. */
76
+ export type EntityField<TData extends RowData = RowData> = {
77
+ /** Property key on the row. Becomes `ColumnDef.field`. */
78
+ field: string & keyof TData
79
+ /**
80
+ * Actual database column, when it differs from `field` (e.g. a Drizzle
81
+ * `createdAt: timestamp('created_at')` maps `field: 'createdAt'` to
82
+ * `dbColumn: 'created_at'`). SQL adapters read/write this column and alias it
83
+ * back to `field`; everything else keys on `field`. Defaults to `field`.
84
+ */
85
+ dbColumn?: string
86
+ /** High-level type. Drives grid `cellDataType`, the form control, and adapter coercion. */
87
+ type: EntityFieldType
88
+ /** Human label. Defaults to a title-cased `field`. Used for the column header and the form label. */
89
+ label?: string
90
+ /** Part of the primary key. Set on exactly one field, or use `EntitySchema.idField`. */
91
+ primaryKey?: boolean
92
+ /**
93
+ * Server-owned / not user-editable (e.g. `id`, `createdAt`). Shown in the
94
+ * grid, read-only in the edit panel, and omitted from create payloads.
95
+ */
96
+ readonly?: boolean
97
+ /** Required on create / update. Enforced by the edit panel and generated server validators. */
98
+ required?: boolean
99
+ /** Built-in validation, enforced by the edit panel with no external library. */
100
+ min?: number
101
+ max?: number
102
+ minLength?: number
103
+ maxLength?: number
104
+ /** Regex source string a text value must match. */
105
+ pattern?: string
106
+ /** Convenience format check for text fields. */
107
+ format?: 'email' | 'url'
108
+ /**
109
+ * Hide the field. `true` hides it everywhere; the object form hides it from
110
+ * just the grid or just the form (e.g. a password shown in the form, never
111
+ * the grid).
112
+ */
113
+ hidden?: boolean | { grid?: boolean; form?: boolean }
114
+ /**
115
+ * Options for `type: 'enum'`. Reuses the grid's `CellEditorOption`
116
+ * (`{ value, label, color? }`) so grid and form render identically.
117
+ */
118
+ options?: ReadonlyArray<CellEditorOption>
119
+ /** For `type: 'relation'`: the related entity, the local FK, and which related field to display. */
120
+ relation?: {
121
+ /** Name of the related `EntitySchema`. */
122
+ entity: string
123
+ /** Local column storing the related id. Defaults to `field`. */
124
+ foreignKey?: string
125
+ /** Field on the related entity to show to the user. */
126
+ labelField: string
127
+ }
128
+ /**
129
+ * Render a file / image upload control in the edit form. The stored value is a
130
+ * URL (or a data URL for the built-in fallback). Provide an upload handler via
131
+ * `SvGridEditPanel`'s `uploads` prop to push to storage and store the URL.
132
+ */
133
+ upload?: {
134
+ /** `accept` attribute, e.g. `'image/*'` or `'.pdf,.csv'`. */
135
+ accept?: string
136
+ /** Show an image preview + thumbnail. */
137
+ image?: boolean
138
+ }
139
+ /**
140
+ * Standard Schema validator (Zod / Valibot / ArkType / ...). Run by the edit
141
+ * panel and reusable verbatim server-side, so a rule is written once.
142
+ */
143
+ validate?: StandardSchemaV1
144
+ /** Default applied when the edit panel creates a new row. */
145
+ defaultValue?: unknown
146
+ /**
147
+ * Derived, non-stored field: its value is computed from the row (e.g. a line
148
+ * total `qty * price` or a full name). Computed fields are display-only -
149
+ * read-only in the grid + form, never sent in a create/update payload. Use
150
+ * `withEntityRules(source, schema)` (or `applyComputed`) to materialize the
151
+ * value onto rows so the grid can sort/filter it within the fetched page.
152
+ */
153
+ computed?: (row: TData) => unknown
154
+ /**
155
+ * No-code computed source: a formula over other fields (e.g. `qty * price`,
156
+ * `first + ' ' + last`). The Studio generator compiles it into `computed`;
157
+ * bare field names resolve to `row.<field>`. Ignored when `computed` is set.
158
+ */
159
+ formula?: string
160
+ /** Merge extra `ColumnDef` props onto the derived grid column (width, align, `cell`, `format`, ...). Escape hatch. */
161
+ column?: Partial<ColumnDef<TableFeatures, TData>>
162
+ /** Override the derived form control. Escape hatch. */
163
+ input?: {
164
+ editorType?: StudioEditorType
165
+ placeholder?: string
166
+ /** Columns spanned in the form's 2-col grid. Defaults to 1. */
167
+ span?: 1 | 2
168
+ help?: string
169
+ /** Mask pattern for the `mask` editor (e.g. `'(999) 000-0000'`). */
170
+ mask?: string
171
+ /** Number/slider step increment (SvNumberInput / SvSlider). */
172
+ step?: number
173
+ /** Number decimal places (SvNumberInput). */
174
+ precision?: number
175
+ /** Number affix, e.g. `'$'` prefix or `'%'` suffix (SvNumberInput). */
176
+ prefix?: string
177
+ suffix?: string
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Business-logic hooks run at the mutation boundary by `withEntityRules`. Before
183
+ * hooks may transform the payload (return a new value) or throw to reject; the
184
+ * `validate` hook does cross-field form validation (return a `{ field: message }`
185
+ * map of errors, or `null`/`{}` when valid) and is also run by the edit panel.
186
+ */
187
+ export type EntityHooks<TData extends RowData = RowData> = {
188
+ /** Transform / validate a create payload. Return the payload to use, or throw. */
189
+ beforeCreate?: (values: Partial<TData>) => Partial<TData> | Promise<Partial<TData>>
190
+ /** Transform / validate an update payload. Return the payload to use, or throw. */
191
+ beforeUpdate?: (id: string, values: Partial<TData>) => Partial<TData> | Promise<Partial<TData>>
192
+ /** Side effect after a row is created. */
193
+ afterCreate?: (row: TData) => void | Promise<void>
194
+ /** Side effect after a row is updated. */
195
+ afterUpdate?: (row: TData) => void | Promise<void>
196
+ /** Veto a delete by throwing. */
197
+ beforeDelete?: (id: string) => void | Promise<void>
198
+ /** Side effect after a row is deleted. */
199
+ afterDelete?: (id: string) => void | Promise<void>
200
+ /** Cross-field form validation. Return errors keyed by field, or null when valid. */
201
+ validate?: (values: Partial<TData>) => Record<string, string> | null | Promise<Record<string, string> | null>
202
+ }
203
+
204
+ /** A complete entity: its fields plus how to identify a row. */
205
+ export type EntitySchema<TData extends RowData = RowData> = {
206
+ /** Stable machine name / table name, e.g. `'customers'`. */
207
+ name: string
208
+ /** Singular display label, e.g. `'Customer'`. Defaults to a title-cased `name`. */
209
+ label?: string
210
+ fields: ReadonlyArray<EntityField<TData>>
211
+ /** Primary-key field. Optional when exactly one field sets `primaryKey`. */
212
+ idField?: string & keyof TData
213
+ /** Business-logic hooks run at the mutation boundary (see `withEntityRules`). */
214
+ hooks?: EntityHooks<TData>
215
+ /**
216
+ * No-code cross-field validation rules (authored in the Studio designer). The
217
+ * generator compiles them into `hooks.validate`. Each rule asserts a condition;
218
+ * when it fails, `message` is attached to `field`.
219
+ */
220
+ validations?: ValidationRuleSpec[]
221
+ }
222
+
223
+ /** The comparison a {@link ValidationRuleSpec} asserts must hold. */
224
+ export type ValidationOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'required' | 'maxLen' | 'minLen'
225
+ /** One no-code validation rule: assert `field <op> value`, else show `message`.
226
+ * `compareTo` (another field name) asserts a cross-field comparison instead of `value`. */
227
+ export type ValidationRuleSpec = {
228
+ field: string
229
+ op: ValidationOp
230
+ value?: string | number
231
+ /** Compare against another field's value (cross-field) instead of `value`. */
232
+ compareTo?: string
233
+ message: string
234
+ }
235
+
236
+ /** Normalized descriptor the edit panel renders from (one per visible-in-form field). */
237
+ export type FormFieldDescriptor = {
238
+ field: string
239
+ label: string
240
+ editorType: StudioEditorType
241
+ type: EntityFieldType
242
+ required: boolean
243
+ readonly: boolean
244
+ /** Mask pattern for the `mask` editor. */
245
+ mask?: string
246
+ /** Number editor options (step / decimals / affixes). */
247
+ step?: number
248
+ precision?: number
249
+ prefix?: string
250
+ suffix?: string
251
+ min?: number
252
+ max?: number
253
+ minLength?: number
254
+ maxLength?: number
255
+ pattern?: string
256
+ format?: 'email' | 'url'
257
+ options?: ReadonlyArray<CellEditorOption>
258
+ relation?: EntityField['relation']
259
+ upload?: EntityField['upload']
260
+ validate?: StandardSchemaV1
261
+ defaultValue?: unknown
262
+ placeholder?: string
263
+ help?: string
264
+ span: 1 | 2
265
+ /** Derived value function, when the field is computed (rendered read-only, live). */
266
+ computed?: (row: RowData) => unknown
267
+ }
268
+
269
+ // --- derivation ------------------------------------------------------------
270
+
271
+ /** `first_name` / `firstName` / `first-name` -> `First Name`. */
272
+ export function titleCase(field: string): string {
273
+ return field
274
+ .replace(/[_-]+/g, ' ')
275
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
276
+ .replace(/\s+/g, ' ')
277
+ .trim()
278
+ .replace(/\b\w/g, (c) => c.toUpperCase())
279
+ }
280
+
281
+ /**
282
+ * Resolve the primary-key field id. Precedence: explicit `schema.idField`,
283
+ * then the single field flagged `primaryKey`, then a field literally named
284
+ * `id`. Throws if none resolves, since every downstream consumer (grid
285
+ * `getRowId`, edit panel, adapters) needs a stable key.
286
+ */
287
+ export function resolveIdField<TData extends RowData>(schema: EntitySchema<TData>): string {
288
+ if (schema.idField) return schema.idField
289
+ const flagged = schema.fields.filter((f) => f.primaryKey)
290
+ if (flagged.length === 1) return flagged[0]!.field
291
+ if (flagged.length > 1) {
292
+ throw new Error(
293
+ `EntitySchema "${schema.name}": multiple fields set primaryKey (${flagged
294
+ .map((f) => f.field)
295
+ .join(', ')}). Set exactly one, or use idField for a composite key.`,
296
+ )
297
+ }
298
+ const byName = schema.fields.find((f) => f.field === 'id')
299
+ if (byName) return byName.field
300
+ throw new Error(
301
+ `EntitySchema "${schema.name}": no primary key. Set idField, flag a field primaryKey, or name a field "id".`,
302
+ )
303
+ }
304
+
305
+ /**
306
+ * Map a form control (StudioEditorType) to a valid grid cell editor. The form-only
307
+ * editors have no in-cell equivalent, so they degrade: `slider` -> `number`,
308
+ * `phone`/`country`/`mask` -> `text`. Returns undefined when no override is set.
309
+ */
310
+ function gridEditorType(t: StudioEditorType | undefined): CellEditorType | undefined {
311
+ if (!t) return undefined
312
+ if (t === 'slider') return 'number'
313
+ if (t === 'phone' || t === 'country' || t === 'mask') return 'text'
314
+ return t
315
+ }
316
+
317
+ /** Grid render defaults per entity type: which `cellDataType` + `editorType` a field maps to. */
318
+ function gridTypeFor(type: EntityFieldType): {
319
+ cellDataType?: CellDataType
320
+ editorType: CellEditorType
321
+ } {
322
+ switch (type) {
323
+ case 'number':
324
+ return { cellDataType: 'number', editorType: 'number' }
325
+ case 'boolean':
326
+ return { cellDataType: 'boolean', editorType: 'checkbox' }
327
+ case 'date':
328
+ return { cellDataType: 'date', editorType: 'date' }
329
+ case 'dateString':
330
+ return { cellDataType: 'dateString', editorType: 'date' }
331
+ case 'datetime':
332
+ return { editorType: 'datetime' }
333
+ case 'enum':
334
+ return { editorType: 'select' }
335
+ case 'relation':
336
+ return { editorType: 'select' }
337
+ case 'json':
338
+ return { editorType: 'textarea' }
339
+ case 'text':
340
+ default:
341
+ return { cellDataType: 'text', editorType: 'text' }
342
+ }
343
+ }
344
+
345
+ function hiddenFor(field: { hidden?: EntityField['hidden'] }, surface: 'grid' | 'form'): boolean {
346
+ if (field.hidden === true) return true
347
+ if (field.hidden && typeof field.hidden === 'object') return field.hidden[surface] === true
348
+ return false
349
+ }
350
+
351
+ /**
352
+ * Derive grid columns from a schema. Read-only fields become non-editable
353
+ * columns; `enum` carries its options through as `editorOptions`. The
354
+ * per-field `column` escape hatch is merged last, so any explicit grid prop
355
+ * wins over the derived defaults.
356
+ */
357
+ export function schemaToColumns<TData extends RowData>(
358
+ schema: EntitySchema<TData>,
359
+ ): Array<ColumnDef<TableFeatures, TData>> {
360
+ return schema.fields
361
+ .filter((f) => !hiddenFor(f, 'grid'))
362
+ .map((f) => {
363
+ const { cellDataType, editorType } = gridTypeFor(f.type)
364
+ // A number field whose label carries a "$" is money: format it as currency.
365
+ const isMoney = f.type === 'number' && /\$/.test(f.label ?? '')
366
+ const col: ColumnDef<TableFeatures, TData> = {
367
+ field: f.field as keyof TData & string,
368
+ header: f.label ?? titleCase(f.field),
369
+ editable: !f.readonly && !f.computed,
370
+ ...(cellDataType ? { cellDataType } : {}),
371
+ ...(gridEditorType(f.input?.editorType) ?? editorType ? { editorType: (gridEditorType(f.input?.editorType) ?? editorType) as ColumnDef<TableFeatures, TData>['editorType'] } : {}),
372
+ ...(f.options ? { editorOptions: f.options } : {}),
373
+ ...(isMoney ? { format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } } : {}),
374
+ // A computed field derives its cell value via the grid's value accessor,
375
+ // so it renders, sorts, and filters client-side with no materialization.
376
+ ...(f.computed ? { fieldFn: f.computed as (row: TData) => unknown } : {}),
377
+ ...(f.column ?? {}),
378
+ }
379
+ return col
380
+ })
381
+ }
382
+
383
+ /**
384
+ * Derive normalized form-field descriptors for the edit panel. Fields hidden
385
+ * from the form are dropped; the primary key is forced read-only (you never
386
+ * edit an id). The form control is the per-field `input.editorType` override,
387
+ * else the type's grid editor default.
388
+ */
389
+ export function schemaToFormFields<TData extends RowData>(
390
+ schema: EntitySchema<TData>,
391
+ ): FormFieldDescriptor[] {
392
+ const idField = resolveIdField(schema)
393
+ return schema.fields
394
+ .filter((f) => !hiddenFor(f, 'form'))
395
+ .map((f) => {
396
+ const { editorType } = gridTypeFor(f.type)
397
+ const isId = f.field === idField
398
+ return {
399
+ field: f.field,
400
+ label: f.label ?? titleCase(f.field),
401
+ editorType: f.input?.editorType ?? editorType,
402
+ type: f.type,
403
+ required: !!f.required && !isId,
404
+ readonly: !!f.readonly || isId || !!f.computed,
405
+ mask: f.input?.mask,
406
+ step: f.input?.step,
407
+ precision: f.input?.precision,
408
+ prefix: f.input?.prefix,
409
+ suffix: f.input?.suffix,
410
+ min: f.min,
411
+ max: f.max,
412
+ minLength: f.minLength,
413
+ maxLength: f.maxLength,
414
+ pattern: f.pattern,
415
+ format: f.format,
416
+ options: f.options,
417
+ relation: f.relation,
418
+ upload: f.upload,
419
+ validate: f.validate,
420
+ defaultValue: f.defaultValue,
421
+ placeholder: f.input?.placeholder,
422
+ help: f.input?.help,
423
+ span: f.input?.span ?? (f.type === 'json' ? 2 : 1),
424
+ computed: f.computed as ((row: RowData) => unknown) | undefined,
425
+ }
426
+ })
427
+ }
428
+
429
+ /**
430
+ * Run a field's Standard Schema validator against a value. Returns `null`
431
+ * when valid (or when the field has no validator), else the first issue
432
+ * message. Async validators (some Standard Schema vendors) are awaited.
433
+ */
434
+ export async function validateField(
435
+ field: Pick<EntityField, 'validate'>,
436
+ value: unknown,
437
+ ): Promise<string | null> {
438
+ if (!field.validate) return null
439
+ const result = await field.validate['~standard'].validate(value)
440
+ const first = 'issues' in result ? result.issues?.[0] : undefined
441
+ return first ? first.message : null
442
+ }
443
+
444
+ /**
445
+ * Materialize every `computed` field's value onto a copy of the row, so the
446
+ * grid / export see a real value to render, sort, and filter. Each computed
447
+ * field is evaluated against the original (stored) row, so computed fields
448
+ * cannot depend on one another. Returns the row unchanged when there are none.
449
+ */
450
+ export function applyComputed<TData extends RowData>(schema: EntitySchema<TData>, row: TData): TData {
451
+ const computed = schema.fields.filter((f) => f.computed)
452
+ if (computed.length === 0) return row
453
+ const out = { ...(row as RowData) }
454
+ for (const f of computed) out[f.field] = f.computed!(row)
455
+ return out as TData
456
+ }
457
+
458
+ /**
459
+ * Run the schema-level cross-field `hooks.validate`. Returns a `{ field:
460
+ * message }` map (empty when valid or when no validate hook is set). Used by
461
+ * both the edit panel (form errors) and `withEntityRules` (write guard).
462
+ */
463
+ export async function validateEntity<TData extends RowData>(
464
+ schema: EntitySchema<TData>,
465
+ values: Partial<TData>,
466
+ ): Promise<Record<string, string>> {
467
+ if (!schema.hooks?.validate) return {}
468
+ return (await schema.hooks.validate(values)) ?? {}
469
+ }
470
+
471
+ /** Pick the best human-facing column of a schema: a name/title/label field, else the first text field, else the id. */
472
+ export function pickLabelField<TData extends RowData>(schema: EntitySchema<TData>): string {
473
+ const byName = schema.fields.find((f) => /^(name|title|label)$/i.test(f.field))
474
+ if (byName) return byName.field
475
+ const idField = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field
476
+ const text = schema.fields.find((f) => f.type === 'text' && f.field !== idField)
477
+ return text?.field ?? idField ?? schema.fields[0]!.field
478
+ }
479
+
480
+ /**
481
+ * Resolve each `relation` field's `labelField` against the actual related
482
+ * schema. When you introspect several tables, foreign keys only know the related
483
+ * table name - this fills in a good display field (name / title / ...) from the
484
+ * related schema. Returns new schemas; inputs are untouched.
485
+ */
486
+ export function linkRelationLabels<TData extends RowData>(
487
+ schemas: EntitySchema<TData>[],
488
+ ): EntitySchema<TData>[] {
489
+ const byName = new Map(schemas.map((s) => [s.name, s]))
490
+ return schemas.map((schema) => ({
491
+ ...schema,
492
+ fields: schema.fields.map((f) => {
493
+ if (f.type !== 'relation' || !f.relation) return f
494
+ const related = byName.get(f.relation.entity)
495
+ if (!related) return f
496
+ return { ...f, relation: { ...f.relation, labelField: pickLabelField(related) } }
497
+ }),
498
+ }))
499
+ }