@businessdash/sdk 0.9.60

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +899 -0
  3. package/dist/adapters-revalidate.d.ts +44 -0
  4. package/dist/adapters-revalidate.d.ts.map +1 -0
  5. package/dist/adapters-revalidate.js +68 -0
  6. package/dist/adapters-revalidate.js.map +1 -0
  7. package/dist/alpha.d.ts +21 -0
  8. package/dist/alpha.d.ts.map +1 -0
  9. package/dist/alpha.js +21 -0
  10. package/dist/alpha.js.map +1 -0
  11. package/dist/analytics-core.d.ts +34 -0
  12. package/dist/analytics-core.d.ts.map +1 -0
  13. package/dist/analytics-core.js +138 -0
  14. package/dist/analytics-core.js.map +1 -0
  15. package/dist/analytics-server.d.ts +54 -0
  16. package/dist/analytics-server.d.ts.map +1 -0
  17. package/dist/analytics-server.js +78 -0
  18. package/dist/analytics-server.js.map +1 -0
  19. package/dist/attribution-logo.d.ts +2 -0
  20. package/dist/attribution-logo.d.ts.map +1 -0
  21. package/dist/attribution-logo.js +5 -0
  22. package/dist/attribution-logo.js.map +1 -0
  23. package/dist/auth-handler.d.ts +92 -0
  24. package/dist/auth-handler.d.ts.map +1 -0
  25. package/dist/auth-handler.js +277 -0
  26. package/dist/auth-handler.js.map +1 -0
  27. package/dist/biab-forms.css +484 -0
  28. package/dist/cli.d.ts +46 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +1824 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/client.d.ts +1681 -0
  33. package/dist/client.d.ts.map +1 -0
  34. package/dist/client.js +2845 -0
  35. package/dist/client.js.map +1 -0
  36. package/dist/collections.d.ts +13 -0
  37. package/dist/collections.d.ts.map +1 -0
  38. package/dist/collections.js +13 -0
  39. package/dist/collections.js.map +1 -0
  40. package/dist/contracts.d.ts +14609 -0
  41. package/dist/contracts.d.ts.map +1 -0
  42. package/dist/contracts.js +2428 -0
  43. package/dist/contracts.js.map +1 -0
  44. package/dist/data-model-schema.d.ts +962 -0
  45. package/dist/data-model-schema.d.ts.map +1 -0
  46. package/dist/data-model-schema.js +2295 -0
  47. package/dist/data-model-schema.js.map +1 -0
  48. package/dist/distribution.d.ts +38 -0
  49. package/dist/distribution.d.ts.map +1 -0
  50. package/dist/distribution.js +61 -0
  51. package/dist/distribution.js.map +1 -0
  52. package/dist/element.d.ts +121 -0
  53. package/dist/element.d.ts.map +1 -0
  54. package/dist/element.js +276 -0
  55. package/dist/element.js.map +1 -0
  56. package/dist/enums.d.ts +25 -0
  57. package/dist/enums.d.ts.map +1 -0
  58. package/dist/enums.js +38 -0
  59. package/dist/enums.js.map +1 -0
  60. package/dist/errors.d.ts +195 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/errors.js +188 -0
  63. package/dist/errors.js.map +1 -0
  64. package/dist/forms-core/autocomplete.d.ts +37 -0
  65. package/dist/forms-core/autocomplete.d.ts.map +1 -0
  66. package/dist/forms-core/autocomplete.js +82 -0
  67. package/dist/forms-core/autocomplete.js.map +1 -0
  68. package/dist/forms-core/business-function.d.ts +61 -0
  69. package/dist/forms-core/business-function.d.ts.map +1 -0
  70. package/dist/forms-core/business-function.js +149 -0
  71. package/dist/forms-core/business-function.js.map +1 -0
  72. package/dist/forms-core/calculation.d.ts +72 -0
  73. package/dist/forms-core/calculation.d.ts.map +1 -0
  74. package/dist/forms-core/calculation.js +103 -0
  75. package/dist/forms-core/calculation.js.map +1 -0
  76. package/dist/forms-core/controller.d.ts +249 -0
  77. package/dist/forms-core/controller.d.ts.map +1 -0
  78. package/dist/forms-core/controller.js +590 -0
  79. package/dist/forms-core/controller.js.map +1 -0
  80. package/dist/forms-core/elements.d.ts +44 -0
  81. package/dist/forms-core/elements.d.ts.map +1 -0
  82. package/dist/forms-core/elements.js +68 -0
  83. package/dist/forms-core/elements.js.map +1 -0
  84. package/dist/forms-core/email-domains.d.ts +10 -0
  85. package/dist/forms-core/email-domains.d.ts.map +1 -0
  86. package/dist/forms-core/email-domains.js +69 -0
  87. package/dist/forms-core/email-domains.js.map +1 -0
  88. package/dist/forms-core/file-rules.d.ts +51 -0
  89. package/dist/forms-core/file-rules.d.ts.map +1 -0
  90. package/dist/forms-core/file-rules.js +105 -0
  91. package/dist/forms-core/file-rules.js.map +1 -0
  92. package/dist/forms-core/formula.d.ts +114 -0
  93. package/dist/forms-core/formula.d.ts.map +1 -0
  94. package/dist/forms-core/formula.js +1170 -0
  95. package/dist/forms-core/formula.js.map +1 -0
  96. package/dist/forms-core/index.d.ts +24 -0
  97. package/dist/forms-core/index.d.ts.map +1 -0
  98. package/dist/forms-core/index.js +28 -0
  99. package/dist/forms-core/index.js.map +1 -0
  100. package/dist/forms-core/units.d.ts +49 -0
  101. package/dist/forms-core/units.d.ts.map +1 -0
  102. package/dist/forms-core/units.js +255 -0
  103. package/dist/forms-core/units.js.map +1 -0
  104. package/dist/forms-core/us-states.d.ts +14 -0
  105. package/dist/forms-core/us-states.d.ts.map +1 -0
  106. package/dist/forms-core/us-states.js +66 -0
  107. package/dist/forms-core/us-states.js.map +1 -0
  108. package/dist/forms-core/validation.d.ts +266 -0
  109. package/dist/forms-core/validation.d.ts.map +1 -0
  110. package/dist/forms-core/validation.js +850 -0
  111. package/dist/forms-core/validation.js.map +1 -0
  112. package/dist/forms.d.ts +19 -0
  113. package/dist/forms.d.ts.map +1 -0
  114. package/dist/forms.js +29 -0
  115. package/dist/forms.js.map +1 -0
  116. package/dist/gallery-types.d.ts +32 -0
  117. package/dist/gallery-types.d.ts.map +1 -0
  118. package/dist/gallery-types.js +2 -0
  119. package/dist/gallery-types.js.map +1 -0
  120. package/dist/graphql.d.ts +97 -0
  121. package/dist/graphql.d.ts.map +1 -0
  122. package/dist/graphql.js +212 -0
  123. package/dist/graphql.js.map +1 -0
  124. package/dist/index.cjs +19448 -0
  125. package/dist/index.d.cts +19 -0
  126. package/dist/index.d.mts +19 -0
  127. package/dist/index.d.ts +19 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +20 -0
  130. package/dist/index.js.map +1 -0
  131. package/dist/marketing-schema.d.ts +216 -0
  132. package/dist/marketing-schema.d.ts.map +1 -0
  133. package/dist/marketing-schema.js +200 -0
  134. package/dist/marketing-schema.js.map +1 -0
  135. package/dist/mcp.d.ts +42 -0
  136. package/dist/mcp.d.ts.map +1 -0
  137. package/dist/mcp.js +87 -0
  138. package/dist/mcp.js.map +1 -0
  139. package/dist/native.d.ts +45 -0
  140. package/dist/native.d.ts.map +1 -0
  141. package/dist/native.js +43 -0
  142. package/dist/native.js.map +1 -0
  143. package/dist/next-revalidate.d.ts +43 -0
  144. package/dist/next-revalidate.d.ts.map +1 -0
  145. package/dist/next-revalidate.js +84 -0
  146. package/dist/next-revalidate.js.map +1 -0
  147. package/dist/parallel-pages.d.ts +102 -0
  148. package/dist/parallel-pages.d.ts.map +1 -0
  149. package/dist/parallel-pages.js +108 -0
  150. package/dist/parallel-pages.js.map +1 -0
  151. package/dist/payment-icons.d.ts +66 -0
  152. package/dist/payment-icons.d.ts.map +1 -0
  153. package/dist/payment-icons.js +160 -0
  154. package/dist/payment-icons.js.map +1 -0
  155. package/dist/proxy.d.ts +67 -0
  156. package/dist/proxy.d.ts.map +1 -0
  157. package/dist/proxy.js +97 -0
  158. package/dist/proxy.js.map +1 -0
  159. package/dist/react-analytics.d.ts +39 -0
  160. package/dist/react-analytics.d.ts.map +1 -0
  161. package/dist/react-analytics.js +59 -0
  162. package/dist/react-analytics.js.map +1 -0
  163. package/dist/react-attribution.d.ts +99 -0
  164. package/dist/react-attribution.d.ts.map +1 -0
  165. package/dist/react-attribution.js +52 -0
  166. package/dist/react-attribution.js.map +1 -0
  167. package/dist/react-marketing.d.ts +181 -0
  168. package/dist/react-marketing.d.ts.map +1 -0
  169. package/dist/react-marketing.js +215 -0
  170. package/dist/react-marketing.js.map +1 -0
  171. package/dist/react.d.ts +727 -0
  172. package/dist/react.d.ts.map +1 -0
  173. package/dist/react.js +2418 -0
  174. package/dist/react.js.map +1 -0
  175. package/dist/reserved-paths.d.ts +24 -0
  176. package/dist/reserved-paths.d.ts.map +1 -0
  177. package/dist/reserved-paths.js +60 -0
  178. package/dist/reserved-paths.js.map +1 -0
  179. package/dist/revalidate-verify.d.ts +30 -0
  180. package/dist/revalidate-verify.d.ts.map +1 -0
  181. package/dist/revalidate-verify.js +74 -0
  182. package/dist/revalidate-verify.js.map +1 -0
  183. package/dist/sdk.d.ts +678 -0
  184. package/dist/sdk.d.ts.map +1 -0
  185. package/dist/sdk.js +801 -0
  186. package/dist/sdk.js.map +1 -0
  187. package/dist/seo.d.ts +218 -0
  188. package/dist/seo.d.ts.map +1 -0
  189. package/dist/seo.js +309 -0
  190. package/dist/seo.js.map +1 -0
  191. package/dist/socials.d.ts +47 -0
  192. package/dist/socials.d.ts.map +1 -0
  193. package/dist/socials.js +172 -0
  194. package/dist/socials.js.map +1 -0
  195. package/dist/static.d.ts +14 -0
  196. package/dist/static.d.ts.map +1 -0
  197. package/dist/static.js +14 -0
  198. package/dist/static.js.map +1 -0
  199. package/dist/url-pattern.d.ts +47 -0
  200. package/dist/url-pattern.d.ts.map +1 -0
  201. package/dist/url-pattern.js +124 -0
  202. package/dist/url-pattern.js.map +1 -0
  203. package/dist/vanilla.d.ts +77 -0
  204. package/dist/vanilla.d.ts.map +1 -0
  205. package/dist/vanilla.js +903 -0
  206. package/dist/vanilla.js.map +1 -0
  207. package/openapi.json +13779 -0
  208. package/package.json +204 -0
  209. package/src/adapters-revalidate.ts +88 -0
  210. package/src/alpha.ts +89 -0
  211. package/src/analytics-core.ts +175 -0
  212. package/src/analytics-server.ts +99 -0
  213. package/src/angular/biab-form.component.ts +1443 -0
  214. package/src/angular/index.ts +26 -0
  215. package/src/attribution-logo.ts +5 -0
  216. package/src/auth-handler.ts +352 -0
  217. package/src/cli.ts +2517 -0
  218. package/src/client.ts +3578 -0
  219. package/src/collections.ts +101 -0
  220. package/src/contracts.ts +3390 -0
  221. package/src/data-model-schema.ts +4042 -0
  222. package/src/distribution.ts +73 -0
  223. package/src/element.ts +321 -0
  224. package/src/enums.ts +65 -0
  225. package/src/errors.ts +338 -0
  226. package/src/forms-core/autocomplete.ts +97 -0
  227. package/src/forms-core/business-function.ts +209 -0
  228. package/src/forms-core/calculation.ts +167 -0
  229. package/src/forms-core/controller.ts +922 -0
  230. package/src/forms-core/elements.ts +86 -0
  231. package/src/forms-core/email-domains.ts +68 -0
  232. package/src/forms-core/file-rules.ts +123 -0
  233. package/src/forms-core/formula.ts +1339 -0
  234. package/src/forms-core/index.ts +122 -0
  235. package/src/forms-core/units.ts +308 -0
  236. package/src/forms-core/us-states.ts +68 -0
  237. package/src/forms-core/validation.ts +1037 -0
  238. package/src/forms.ts +63 -0
  239. package/src/gallery-types.ts +31 -0
  240. package/src/graphql.ts +295 -0
  241. package/src/index.ts +505 -0
  242. package/src/marketing-schema.ts +395 -0
  243. package/src/mcp.ts +108 -0
  244. package/src/native.ts +73 -0
  245. package/src/next-revalidate.ts +124 -0
  246. package/src/parallel-pages.ts +192 -0
  247. package/src/payment-icons.ts +205 -0
  248. package/src/proxy.ts +173 -0
  249. package/src/qwik.tsx +1234 -0
  250. package/src/react-analytics.tsx +76 -0
  251. package/src/react-attribution.tsx +192 -0
  252. package/src/react-marketing.tsx +338 -0
  253. package/src/react.tsx +5495 -0
  254. package/src/reserved-paths.ts +65 -0
  255. package/src/revalidate-verify.ts +98 -0
  256. package/src/sdk.ts +1671 -0
  257. package/src/seo.ts +488 -0
  258. package/src/socials.ts +230 -0
  259. package/src/solid.tsx +1466 -0
  260. package/src/static.ts +21 -0
  261. package/src/styles/biab-forms.css +484 -0
  262. package/src/svelte/BiabForm.svelte +1069 -0
  263. package/src/svelte/context.ts +57 -0
  264. package/src/svelte/index.ts +79 -0
  265. package/src/url-pattern.ts +153 -0
  266. package/src/vanilla.ts +1290 -0
  267. package/src/vue/BiabForm.vue +1538 -0
  268. package/src/vue/index.ts +119 -0
@@ -0,0 +1,4042 @@
1
+ /**
2
+ * `defineDataModel` — declare an org's custom database (tables + enums) in
3
+ * code, push it with `biab-dev sync-data-model`, promote it in the dashboard.
4
+ *
5
+ * Deliberately SEPARATE from `marketing-schema.ts`. A website's content shape
6
+ * and an org's database shape are different artifacts with different review
7
+ * cadence, blast radius, and plausibly different owners — so they get separate
8
+ * config files, commands, endpoints, and API scopes. Nothing is shared but the
9
+ * transport conventions (canonical checksum, draft/published slots).
10
+ *
11
+ * biab.config.ts → defineSiteMarketingSchema → sync-schema
12
+ * biab.data-model.config.ts → defineDataModel → sync-data-model
13
+ *
14
+ * Pure builders — no DB, no network, no side effects. Validation here is about
15
+ * catching a typo in CI before it becomes a push; the server re-validates
16
+ * everything it receives and is the real authority.
17
+ *
18
+ * See Resources/BIAB-Data-Model-CLI-Plan.md for the full design.
19
+ */
20
+
21
+ // ── Field types ──────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Value kinds a field can hold. Mirrors the platform's FieldType catalog.
25
+ * Not every entry is accepted by the push endpoint yet — see
26
+ * `UNSUPPORTED_FIELD_TYPES`.
27
+ */
28
+ import type { EnumEntry } from "./enums.js";
29
+
30
+ export const FIELD_TYPES = [
31
+ "TEXT",
32
+ "NUMBER",
33
+ "NUMERIC",
34
+ "BOOLEAN",
35
+ "DATE",
36
+ "DATE_TIME",
37
+ "UUID",
38
+ "SELECT",
39
+ "MULTI_SELECT",
40
+ "RATING",
41
+ "POSITION",
42
+ "CURRENCY",
43
+ "EMAILS",
44
+ "PHONES",
45
+ "LINKS",
46
+ "ADDRESS",
47
+ "FULL_NAME",
48
+ "ACTOR",
49
+ "RELATION",
50
+ "MORPH_RELATION",
51
+ "FILES",
52
+ "RICH_TEXT",
53
+ "RAW_JSON",
54
+ "ARRAY",
55
+ "TS_VECTOR",
56
+ ] as const;
57
+
58
+ export type FieldType = (typeof FIELD_TYPES)[number];
59
+
60
+ export function isFieldType(value: string): value is FieldType {
61
+ return (FIELD_TYPES as readonly string[]).includes(value);
62
+ }
63
+
64
+ // ── Fields Builder API ──────────────────────────────────────────────────
65
+
66
+ /** Common field options that can be chained. */
67
+ export interface FieldOptions {
68
+ required?: boolean;
69
+ nullable?: boolean;
70
+ defaultValue?: unknown;
71
+ indexed?: boolean;
72
+ unique?: boolean;
73
+ description?: string;
74
+ }
75
+
76
+ /** Relation metadata for relationship descriptors. */
77
+ export interface RelationMetadata {
78
+ type: "one-to-many" | "many-to-many";
79
+ targetCollection: string;
80
+ joinCollectionName?: string;
81
+ fields?: Record<string, AnyFieldBuilder>;
82
+ onDeleteAction?: DeleteAction;
83
+ }
84
+
85
+ /** Builder for a single field definition. */
86
+ export interface FieldBuilder<
87
+ T extends FieldType = FieldType,
88
+ TValue = InferJsType<T>,
89
+ TRequired extends boolean = false,
90
+ TNullable extends boolean = true,
91
+ > {
92
+ readonly __valueType?: TValue;
93
+ readonly __requiredType?: TRequired;
94
+ readonly __nullableType?: TNullable;
95
+ required: () => FieldBuilder<T, TValue, true, false>;
96
+ optional: () => FieldBuilder<T, TValue, false, true>;
97
+ nullable: <TNextNullable extends boolean = true>(
98
+ value?: TNextNullable,
99
+ ) => FieldBuilder<T, TValue, TRequired, TNextNullable>;
100
+ default: (value: unknown) => FieldBuilder<T, TValue, TRequired, TNullable>;
101
+ indexed: (value?: boolean) => FieldBuilder<T, TValue, TRequired, TNullable>;
102
+ unique: (value?: boolean) => FieldBuilder<T, TValue, TRequired, TNullable>;
103
+ description: (text: string) => FieldBuilder<T, TValue, TRequired, TNullable>;
104
+ helper: (text: string) => FieldBuilder<T, TValue, TRequired, TNullable>;
105
+ formElement: (
106
+ element: AllFormElements,
107
+ metadata?: ElementMetadata,
108
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
109
+ validate: (
110
+ rules: ValidationRules,
111
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
112
+ dynamicOptions: (
113
+ config: DynamicOptionsDef,
114
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
115
+ requiredIf: (
116
+ condition: RequiredIfCondition,
117
+ ) => FieldBuilder<T, TValue, false, TNullable>;
118
+ regex: (
119
+ pattern: RegExp | string,
120
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
121
+ check: (
122
+ sqlExpression: string,
123
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
124
+ strategy: (
125
+ mode: StorageStrategy,
126
+ separateTableName?: string,
127
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
128
+ encrypted: () => FieldBuilder<T, TValue, TRequired, TNullable>;
129
+ encryptedLight: () => FieldBuilder<T, TValue, TRequired, TNullable>;
130
+ private: () => FieldBuilder<T, TValue, TRequired, TNullable>;
131
+ references: (
132
+ targetCollection: string,
133
+ onDelete?: "restrict" | "setNull" | "cascade",
134
+ ) => FieldBuilder<"RELATION", string, TRequired, TNullable>;
135
+ /**
136
+ * This record may reference SEVERAL of `targetCollection`.
137
+ *
138
+ * That's the whole declaration — there is no join table to name, design, or
139
+ * maintain. BIAB stores every link in one universal link table, so "many"
140
+ * is a property of the relation rather than a second collection the author
141
+ * has to invent and keep in step.
142
+ *
143
+ * `fields` optionally declares values that belong to the PAIRING rather than
144
+ * to either side ("this technology, at expert level").
145
+ *
146
+ * `joinCollectionName` is accepted for backwards compatibility and ignored;
147
+ * nothing has ever read it.
148
+ */
149
+ belongsToMany: (
150
+ targetCollection: string,
151
+ options?: {
152
+ /** @deprecated Ignored — BIAB has no per-pair join table to name. */
153
+ joinCollectionName?: string;
154
+ fields?: Record<string, AnyFieldBuilder>;
155
+ },
156
+ ) => FieldBuilder<"RELATION", string[], TRequired, TNullable>;
157
+ onDelete: (
158
+ action: DeleteAction,
159
+ ) => FieldBuilder<T, TValue, TRequired, TNullable>;
160
+ build: () => FieldDefinition;
161
+ }
162
+
163
+ export type AnyFieldBuilder = FieldBuilder<
164
+ FieldType,
165
+ unknown,
166
+ boolean,
167
+ boolean
168
+ >;
169
+
170
+ type RequiredFromOptions<O> = O extends { required: true } ? true : false;
171
+
172
+ type NullableFromOptions<O> = O extends { nullable: infer N extends boolean }
173
+ ? N
174
+ : O extends { required: true }
175
+ ? false
176
+ : true;
177
+
178
+ type EnumValue<T> = T extends { values: readonly (infer V extends string)[] }
179
+ ? V
180
+ : T extends readonly (infer Entry)[]
181
+ ? Entry extends string
182
+ ? Entry
183
+ : Entry extends { value: infer V extends string }
184
+ ? V
185
+ : never
186
+ : string;
187
+
188
+ /** Runtime value stored by a measurement field. */
189
+ export interface MeasurementValue {
190
+ value: number;
191
+ unit: string;
192
+ }
193
+
194
+ /** A field builder whose required and nullable flags were supplied as options. */
195
+ export type ConfiguredFieldBuilder<
196
+ T extends FieldType,
197
+ TValue,
198
+ O extends FieldOptions | undefined,
199
+ > = FieldBuilder<T, TValue, RequiredFromOptions<O>, NullableFromOptions<O>>;
200
+
201
+ /** Infer the submitted/runtime value represented by a single field builder. */
202
+ export type InferFieldValue<B> =
203
+ B extends FieldBuilder<
204
+ infer _T,
205
+ infer TValue,
206
+ infer _TRequired,
207
+ infer TNullable
208
+ >
209
+ ? true extends TNullable
210
+ ? TValue | null
211
+ : TValue
212
+ : never;
213
+
214
+ type RequiredFieldKeys<F extends Record<string, AnyFieldBuilder>> = {
215
+ [K in keyof F]-?: F[K] extends FieldBuilder<
216
+ infer _T,
217
+ infer _TValue,
218
+ infer TRequired,
219
+ infer _TNullable
220
+ >
221
+ ? TRequired extends true
222
+ ? K
223
+ : never
224
+ : never;
225
+ }[keyof F];
226
+
227
+ type OptionalFieldKeys<F extends Record<string, AnyFieldBuilder>> = Exclude<
228
+ keyof F,
229
+ RequiredFieldKeys<F>
230
+ >;
231
+
232
+ type Simplify<T> = { [K in keyof T]: T[K] } & {};
233
+
234
+ /** Infer an object value type from a map of BIAB field declarations. */
235
+ export type InferFieldsValues<F extends Record<string, AnyFieldBuilder>> =
236
+ Simplify<
237
+ {
238
+ [K in RequiredFieldKeys<F>]: InferFieldValue<F[K]>;
239
+ } & {
240
+ [K in OptionalFieldKeys<F>]?: InferFieldValue<F[K]> | undefined;
241
+ }
242
+ >;
243
+
244
+ /** Result of calling a fields method (e.g., fields.text()) */
245
+ export interface FieldSpec extends FieldDefinition {}
246
+
247
+ /** The fields namespace object — entry point for the fluent field API. */
248
+ export interface Fields {
249
+ text: <O extends FieldOptions | undefined = undefined>(
250
+ options?: O,
251
+ ) => ConfiguredFieldBuilder<"TEXT", string, O>;
252
+ number: <O extends FieldOptions | undefined = undefined>(
253
+ options?: O,
254
+ ) => ConfiguredFieldBuilder<"NUMBER", number, O>;
255
+ numeric: <O extends FieldOptions | undefined = undefined>(
256
+ options?: O,
257
+ ) => ConfiguredFieldBuilder<"NUMERIC", number, O>;
258
+ boolean: <O extends FieldOptions | undefined = undefined>(
259
+ options?: O,
260
+ ) => ConfiguredFieldBuilder<"BOOLEAN", boolean, O>;
261
+ date: <O extends FieldOptions | undefined = undefined>(
262
+ options?: O,
263
+ ) => ConfiguredFieldBuilder<"DATE", string, O>;
264
+ dateTime: <O extends FieldOptions | undefined = undefined>(
265
+ options?: O,
266
+ ) => ConfiguredFieldBuilder<"DATE_TIME", string, O>;
267
+ uuid: <O extends FieldOptions | undefined = undefined>(
268
+ options?: O,
269
+ ) => ConfiguredFieldBuilder<"UUID", string, O>;
270
+ enum: <
271
+ const T extends
272
+ | readonly (string | { value: string; label?: string })[]
273
+ | { values: readonly string[]; entries: EnumEntry[] },
274
+ O extends FieldOptions | undefined = undefined,
275
+ >(
276
+ values: T,
277
+ options?: O,
278
+ ) => ConfiguredFieldBuilder<"SELECT", EnumValue<T>, O>;
279
+ multiEnum: <
280
+ const T extends
281
+ | readonly (string | { value: string; label?: string })[]
282
+ | { values: readonly string[]; entries: EnumEntry[] },
283
+ O extends FieldOptions | undefined = undefined,
284
+ >(
285
+ values: T,
286
+ options?: O,
287
+ ) => ConfiguredFieldBuilder<"MULTI_SELECT", EnumValue<T>[], O>;
288
+ reference: <
289
+ O extends
290
+ | (FieldOptions & {
291
+ onDelete?: "restrict" | "setNull" | "cascade";
292
+ })
293
+ | undefined = undefined,
294
+ >(
295
+ targetCollection: string,
296
+ options?: O,
297
+ ) => ConfiguredFieldBuilder<"RELATION", string, O>;
298
+ relation: () => FieldBuilder<"RELATION", string>;
299
+ url: <O extends FieldOptions | undefined = undefined>(
300
+ options?: O,
301
+ ) => ConfiguredFieldBuilder<"TEXT", string, O>;
302
+ email: <O extends FieldOptions | undefined = undefined>(
303
+ options?: O,
304
+ ) => ConfiguredFieldBuilder<"EMAILS", string, O>;
305
+ phone: <O extends FieldOptions | undefined = undefined>(
306
+ options?: O,
307
+ ) => ConfiguredFieldBuilder<"PHONES", string, O>;
308
+ richText: <O extends FieldOptions | undefined = undefined>(
309
+ options?: O,
310
+ ) => ConfiguredFieldBuilder<"RICH_TEXT", string, O>;
311
+ json: <O extends FieldOptions | undefined = undefined>(
312
+ options?: O,
313
+ ) => ConfiguredFieldBuilder<"RAW_JSON", unknown, O>;
314
+ array: <O extends FieldOptions | undefined = undefined>(
315
+ options?: O,
316
+ ) => ConfiguredFieldBuilder<"ARRAY", unknown[], O>;
317
+ measurement: <O extends FieldOptions | undefined = undefined>(
318
+ config: MeasurementDef,
319
+ options?: O,
320
+ ) => ConfiguredFieldBuilder<"RAW_JSON", MeasurementValue, O>;
321
+ object: <
322
+ F extends Record<string, AnyFieldBuilder>,
323
+ O extends FieldOptions | undefined = undefined,
324
+ >(
325
+ config: { properties: F } | F,
326
+ options?: O,
327
+ ) => ConfiguredFieldBuilder<"RAW_JSON", InferFieldsValues<F>, O>;
328
+ file: <O extends FieldOptions | undefined = undefined>(
329
+ config?: FileDef,
330
+ options?: O,
331
+ ) => ConfiguredFieldBuilder<"FILES", unknown[], O>;
332
+ address: <O extends FieldOptions | undefined = undefined>(
333
+ config?: AddressDef,
334
+ options?: O,
335
+ ) => ConfiguredFieldBuilder<"RAW_JSON", Record<string, unknown>, O>;
336
+ geo: <O extends FieldOptions | undefined = undefined>(
337
+ options?: O,
338
+ ) => ConfiguredFieldBuilder<"RAW_JSON", Record<string, unknown>, O>;
339
+ }
340
+
341
+ /** Internal builder state */
342
+ interface BuilderState extends FieldOptions {
343
+ type: FieldType | "__relation__";
344
+ options?: SelectOption[];
345
+ relationTarget?: string;
346
+ onDelete?: RelationOnDelete;
347
+ relationMeta?: RelationMetadata;
348
+ relationCardinality?: "one" | "many";
349
+ linkAttributes?: LinkAttributeDefinition[];
350
+ formElement?: AllFormElements;
351
+ elementMetadata?: ElementMetadata;
352
+ validation?: ValidationRules;
353
+ helperText?: string;
354
+ dynamicOptions?: DynamicOptionsDef;
355
+ strategy?: StorageStrategy;
356
+ separateTableName?: string;
357
+ encryption?: EncryptionMode;
358
+ isPrivate?: boolean;
359
+ }
360
+
361
+ /** Runtime implementation shape; public builder generics are compile-time only. */
362
+ interface RuntimeFieldBuilder {
363
+ __relationMeta?: RelationMetadata;
364
+ required(): RuntimeFieldBuilder;
365
+ optional(): RuntimeFieldBuilder;
366
+ nullable(value?: boolean): RuntimeFieldBuilder;
367
+ default(value: unknown): RuntimeFieldBuilder;
368
+ indexed(value?: boolean): RuntimeFieldBuilder;
369
+ unique(value?: boolean): RuntimeFieldBuilder;
370
+ description(text: string): RuntimeFieldBuilder;
371
+ helper(text: string): RuntimeFieldBuilder;
372
+ formElement(
373
+ element: AllFormElements,
374
+ metadata?: ElementMetadata,
375
+ ): RuntimeFieldBuilder;
376
+ validate(rules: ValidationRules): RuntimeFieldBuilder;
377
+ dynamicOptions(config: DynamicOptionsDef): RuntimeFieldBuilder;
378
+ requiredIf(condition: RequiredIfCondition): RuntimeFieldBuilder;
379
+ regex(pattern: RegExp | string): RuntimeFieldBuilder;
380
+ check(sqlExpression: string): RuntimeFieldBuilder;
381
+ strategy(
382
+ mode: StorageStrategy,
383
+ separateTableName?: string,
384
+ ): RuntimeFieldBuilder;
385
+ encrypted(): RuntimeFieldBuilder;
386
+ encryptedLight(): RuntimeFieldBuilder;
387
+ private(): RuntimeFieldBuilder;
388
+ references(
389
+ targetCollection: string,
390
+ onDelete?: "restrict" | "setNull" | "cascade",
391
+ ): RuntimeFieldBuilder;
392
+ belongsToMany(
393
+ targetCollection: string,
394
+ options: {
395
+ joinCollectionName: string;
396
+ fields?: Record<string, AnyFieldBuilder>;
397
+ },
398
+ ): RuntimeFieldBuilder;
399
+ onDelete(action: DeleteAction): RuntimeFieldBuilder;
400
+ build(): FieldDefinition;
401
+ }
402
+
403
+ /** Create a field builder with chainable methods. */
404
+ function createFieldBuilder<TBuilder = AnyFieldBuilder>(
405
+ initialState: BuilderState,
406
+ ): TBuilder {
407
+ const state: BuilderState = { ...initialState };
408
+
409
+ const builder: RuntimeFieldBuilder = {
410
+ required() {
411
+ state.required = true;
412
+ state.nullable = false;
413
+ return builder;
414
+ },
415
+ optional() {
416
+ state.required = false;
417
+ state.nullable = true;
418
+ return builder;
419
+ },
420
+ nullable(value = true) {
421
+ state.nullable = value;
422
+ return builder;
423
+ },
424
+ default(value) {
425
+ state.defaultValue = value;
426
+ return builder;
427
+ },
428
+ indexed(value = true) {
429
+ state.indexed = value;
430
+ return builder;
431
+ },
432
+ unique(value = true) {
433
+ state.unique = value;
434
+ return builder;
435
+ },
436
+ description(text) {
437
+ state.description = text;
438
+ return builder;
439
+ },
440
+ helper(text) {
441
+ state.helperText = text;
442
+ return builder;
443
+ },
444
+ formElement(element, metadata) {
445
+ state.formElement = element;
446
+ state.elementMetadata = metadata;
447
+ return builder;
448
+ },
449
+ validate(rules) {
450
+ state.validation = { ...state.validation, ...rules };
451
+ return builder;
452
+ },
453
+ dynamicOptions(config) {
454
+ state.dynamicOptions = config;
455
+ return builder;
456
+ },
457
+ requiredIf(condition) {
458
+ state.validation = { ...state.validation, requiredIf: condition };
459
+ return builder;
460
+ },
461
+ regex(pattern) {
462
+ state.validation = { ...state.validation, regex: pattern };
463
+ return builder;
464
+ },
465
+ check(sqlExpression) {
466
+ state.validation = {
467
+ ...state.validation,
468
+ checkExpression: sqlExpression,
469
+ };
470
+ return builder;
471
+ },
472
+ strategy(mode, separateTableName) {
473
+ state.strategy = mode;
474
+ const meta = state.elementMetadata as ObjectSchemaDef | undefined;
475
+ if (meta && "properties" in meta) {
476
+ meta.strategy = mode;
477
+ if (separateTableName) meta.separateTableName = separateTableName;
478
+ }
479
+ if (separateTableName) state.separateTableName = separateTableName;
480
+ return builder;
481
+ },
482
+ encrypted() {
483
+ state.encryption = "user";
484
+ return builder;
485
+ },
486
+ encryptedLight() {
487
+ state.encryption = "master";
488
+ return builder;
489
+ },
490
+ private() {
491
+ state.isPrivate = true;
492
+ return builder;
493
+ },
494
+ references(targetCollection, onDelete = "restrict") {
495
+ state.relationMeta = { type: "one-to-many", targetCollection };
496
+ builder.__relationMeta = state.relationMeta;
497
+ state.type = "RELATION";
498
+ state.relationTarget = targetCollection;
499
+ // A reference holds ONE target — picking another replaces it, rather
500
+ // than quietly accumulating into a set. (Relations promoted before
501
+ // cardinality existed carry none and keep the old "many" behaviour, so
502
+ // this only applies to models pushed from here on.)
503
+ state.relationCardinality = "one";
504
+ state.onDelete = onDelete;
505
+ return builder;
506
+ },
507
+ belongsToMany(targetCollection, options) {
508
+ state.relationMeta = {
509
+ type: "many-to-many",
510
+ targetCollection,
511
+ ...(options?.joinCollectionName !== undefined
512
+ ? { joinCollectionName: options.joinCollectionName }
513
+ : {}),
514
+ ...(options?.fields !== undefined ? { fields: options.fields } : {}),
515
+ };
516
+ if (state.relationMeta) builder.__relationMeta = state.relationMeta;
517
+ // "RELATION", not the "__relation__" placeholder: the placeholder isn't a
518
+ // real FieldType, so `getRecommendedFormElements` returned nothing for it
519
+ // and the generator skipped the field for having no element to render.
520
+ // A many-to-many is a relation like any other — same type as
521
+ // `.references()`, distinguished only by its cardinality.
522
+ state.type = "RELATION";
523
+ // A many-to-many is still a RELATION pointing at a target — without
524
+ // this the built field carried no `relationTarget` and the server
525
+ // rejected the whole push ("RELATION requires relationTarget"), so
526
+ // belongsToMany type-checked but could never be applied.
527
+ state.relationTarget = targetCollection;
528
+ state.relationCardinality = "many";
529
+ // Values belonging to the PAIRING become typed attributes on the link.
530
+ // Only assign when there are any — `exactOptionalPropertyTypes` treats
531
+ // an explicit `undefined` as different from an absent key.
532
+ const linkAttrs = compileLinkAttributes(options?.fields);
533
+ if (linkAttrs) state.linkAttributes = linkAttrs;
534
+ return builder;
535
+ },
536
+ onDelete(action) {
537
+ if (!state.relationMeta) {
538
+ throw new Error(
539
+ "onDelete() requires a relationship — chain it after .references() or .belongsToMany().",
540
+ );
541
+ }
542
+ state.relationMeta.onDeleteAction = action;
543
+ builder.__relationMeta = state.relationMeta;
544
+ if (action !== "NO ACTION") {
545
+ state.onDelete = DELETE_ACTION_TO_ON_DELETE[action]!;
546
+ } else {
547
+ delete state.onDelete;
548
+ }
549
+ return builder;
550
+ },
551
+ build() {
552
+ return buildFieldDefinition(state);
553
+ },
554
+ };
555
+
556
+ return builder as unknown as TBuilder;
557
+ }
558
+
559
+ /** Extract relation metadata from a builder (used by collection()). */
560
+ function getRelationMeta(
561
+ builder: AnyFieldBuilder,
562
+ ): RelationMetadata | undefined {
563
+ return (
564
+ builder as unknown as { __relationMeta: RelationMetadata | undefined }
565
+ ).__relationMeta;
566
+ }
567
+
568
+ /** Convert snake_case or camelCase to Title Case for labels. */
569
+ function toLabel(text: string): string {
570
+ // Split on underscores, hyphens, or camelCase boundaries
571
+ return text
572
+ .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase -> "camel Case"
573
+ .replace(/[_-]+/g, " ") // snake_case, kebab-case -> "snake case"
574
+ .trim()
575
+ .split(/\s+/)
576
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
577
+ .join(" ");
578
+ }
579
+
580
+ /**
581
+ * Generate a universal identifier.
582
+ *
583
+ * A declaration is evaluated on every CLI run, so identities derived from
584
+ * collection/field names MUST be deterministic. Otherwise an unchanged config
585
+ * looks like a drop-and-recreate migration. Call without a seed only for the
586
+ * legacy object API, where callers already provide explicit identities.
587
+ */
588
+ function generateUniversalIdentifier(seed?: string): string {
589
+ if (seed !== undefined) {
590
+ const words = [2166136261, 2246822519, 3266489917, 668265263];
591
+ for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
592
+ let hash = words[wordIndex]!;
593
+ for (let index = 0; index < seed.length; index++) {
594
+ hash ^= seed.charCodeAt(index) + wordIndex * 31;
595
+ hash = Math.imul(hash, 16777619 + wordIndex * 2);
596
+ }
597
+ words[wordIndex] = hash >>> 0;
598
+ }
599
+ const hex = words
600
+ .map((word) => word.toString(16).padStart(8, "0"))
601
+ .join("");
602
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
603
+ }
604
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
605
+ return crypto.randomUUID();
606
+ }
607
+ // Fallback for non-browser environments
608
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
609
+ const r = (Math.random() * 16) | 0;
610
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
611
+ return v.toString(16);
612
+ });
613
+ }
614
+
615
+ /** Convert field options to SelectOption array for enums. */
616
+ function optionsToSelectOptions(
617
+ values:
618
+ | readonly (string | { value: string; label?: string })[]
619
+ | { values: readonly string[]; entries: EnumEntry[] },
620
+ ): SelectOption[] {
621
+ // Handle both raw arrays and Enums() return objects
622
+ const arr = Array.isArray(values)
623
+ ? values
624
+ : (values as { entries: (string | { value: string; label?: string })[] })
625
+ .entries;
626
+ return arr.map((v, index) => {
627
+ if (typeof v === "string") {
628
+ return {
629
+ value: v,
630
+ label: toLabel(v),
631
+ position: index,
632
+ };
633
+ }
634
+ return {
635
+ value: v.value,
636
+ label: v.label ?? toLabel(v.value),
637
+ position: index,
638
+ };
639
+ });
640
+ }
641
+
642
+ /**
643
+ * Compile a `belongsToMany({ fields })` declaration into link-attribute
644
+ * definitions — the typed payload each link row may carry.
645
+ *
646
+ * A join's columns are declared with the same field builders as a collection's,
647
+ * but a link is an EDGE, not a record: only a small set of value kinds makes
648
+ * sense on one. Anything richer is asking for a real collection, so it maps to
649
+ * TEXT rather than silently pretending to be supported.
650
+ */
651
+ function compileLinkAttributes(
652
+ fields: Record<string, AnyFieldBuilder> | undefined,
653
+ ): LinkAttributeDefinition[] | undefined {
654
+ if (!fields) return undefined;
655
+ const out: LinkAttributeDefinition[] = [];
656
+ for (const [name, builder] of Object.entries(fields)) {
657
+ const built = builder.build();
658
+ const type: LinkAttributeDefinition["type"] =
659
+ built.type === "NUMBER" || built.type === "NUMERIC"
660
+ ? "NUMBER"
661
+ : built.type === "BOOLEAN"
662
+ ? "BOOLEAN"
663
+ : built.type === "SELECT"
664
+ ? "SELECT"
665
+ : "TEXT";
666
+ out.push({
667
+ name,
668
+ label: built.label || name,
669
+ type,
670
+ ...(type === "SELECT" && built.options
671
+ ? { options: built.options.map((o) => o.value) }
672
+ : {}),
673
+ ...(built.isRequired ? { isRequired: true } : {}),
674
+ ...(built.description ? { description: built.description } : {}),
675
+ });
676
+ }
677
+ return out.length > 0 ? out : undefined;
678
+ }
679
+
680
+ /** Build a complete FieldDefinition from builder state. */
681
+ function buildFieldDefinition(state: BuilderState): FieldDefinition {
682
+ const universalIdentifier = generateUniversalIdentifier();
683
+
684
+ const recommendedFormElements = isFieldType(state.type)
685
+ ? getRecommendedFormElements(state.type)
686
+ : [];
687
+
688
+ const fieldDef: FieldDefinition = {
689
+ universalIdentifier,
690
+ name: "", // Will be set by collection()
691
+ label: "", // Will be set by collection()
692
+ type: state.type === "__relation__" ? "RELATION" : state.type,
693
+ ...(state.description !== undefined
694
+ ? { description: state.description }
695
+ : {}),
696
+ ...(state.required !== undefined ? { isRequired: state.required } : {}),
697
+ isNullable: state.nullable ?? !state.required,
698
+ ...(state.unique !== undefined ? { isUnique: state.unique } : {}),
699
+ defaultValue: state.defaultValue,
700
+ ...(state.indexed !== undefined ? { isIndexed: state.indexed } : {}),
701
+ ...(state.formElement !== undefined
702
+ ? { formElement: state.formElement }
703
+ : {}),
704
+ ...(state.elementMetadata !== undefined
705
+ ? { elementMetadata: state.elementMetadata }
706
+ : {}),
707
+ ...(recommendedFormElements.length > 0 ? { recommendedFormElements } : {}),
708
+ ...(state.validation !== undefined ? { validation: state.validation } : {}),
709
+ ...(state.helperText !== undefined ? { helperText: state.helperText } : {}),
710
+ ...(state.dynamicOptions !== undefined
711
+ ? { dynamicOptions: state.dynamicOptions }
712
+ : {}),
713
+ ...(state.options !== undefined ? { options: state.options } : {}),
714
+ ...(state.relationTarget !== undefined
715
+ ? { relationTarget: state.relationTarget }
716
+ : {}),
717
+ ...(state.relationCardinality !== undefined
718
+ ? { relationCardinality: state.relationCardinality }
719
+ : {}),
720
+ ...(state.linkAttributes !== undefined
721
+ ? { linkAttributes: state.linkAttributes }
722
+ : {}),
723
+ ...(state.onDelete !== undefined ? { onDelete: state.onDelete } : {}),
724
+ ...(state.strategy !== undefined ? { strategy: state.strategy } : {}),
725
+ ...(state.separateTableName !== undefined
726
+ ? { separateTableName: state.separateTableName }
727
+ : {}),
728
+ ...(state.encryption !== undefined && state.encryption !== "none"
729
+ ? { encryption: state.encryption }
730
+ : {}),
731
+ ...(state.isPrivate ? { isPrivate: true } : {}),
732
+ };
733
+
734
+ // Set position to a default (will be overridden by collection)
735
+ fieldDef.position = 0;
736
+
737
+ return fieldDef;
738
+ }
739
+
740
+ /** The fields builder namespace — exported for consumer use. */
741
+ export const fields: Fields = {
742
+ text(options) {
743
+ return createFieldBuilder({ type: "TEXT", ...options });
744
+ },
745
+ number(options) {
746
+ return createFieldBuilder({ type: "NUMBER", ...options });
747
+ },
748
+ numeric(options) {
749
+ return createFieldBuilder({ type: "NUMERIC", ...options });
750
+ },
751
+ boolean(options) {
752
+ return createFieldBuilder({ type: "BOOLEAN", ...options });
753
+ },
754
+ date(options) {
755
+ return createFieldBuilder({ type: "DATE", ...options });
756
+ },
757
+ dateTime(options) {
758
+ return createFieldBuilder({ type: "DATE_TIME", ...options });
759
+ },
760
+ uuid(options) {
761
+ return createFieldBuilder({ type: "UUID", ...options });
762
+ },
763
+ enum(values, options) {
764
+ return createFieldBuilder({
765
+ type: "SELECT",
766
+ options: optionsToSelectOptions(values),
767
+ ...options,
768
+ });
769
+ },
770
+ multiEnum(values, options) {
771
+ return createFieldBuilder({
772
+ type: "MULTI_SELECT",
773
+ options: optionsToSelectOptions(values),
774
+ ...options,
775
+ });
776
+ },
777
+ reference(targetCollection, options) {
778
+ return createFieldBuilder({
779
+ type: "RELATION",
780
+ relationTarget: targetCollection, // Will be resolved to universalIdentifier by collection()
781
+ onDelete: options?.onDelete ?? "restrict",
782
+ ...options,
783
+ });
784
+ },
785
+ relation() {
786
+ return createFieldBuilder({ type: "__relation__" });
787
+ },
788
+ url(options) {
789
+ return createFieldBuilder({ type: "TEXT", ...options });
790
+ },
791
+ email(options) {
792
+ return createFieldBuilder({ type: "EMAILS", ...options });
793
+ },
794
+ phone(options) {
795
+ return createFieldBuilder({ type: "PHONES", ...options });
796
+ },
797
+ richText(options) {
798
+ return createFieldBuilder({ type: "RICH_TEXT", ...options });
799
+ },
800
+ json(options) {
801
+ return createFieldBuilder({ type: "RAW_JSON", ...options });
802
+ },
803
+ array(options) {
804
+ return createFieldBuilder({ type: "ARRAY", ...options });
805
+ },
806
+ measurement(config, options) {
807
+ return createFieldBuilder({
808
+ type: "RAW_JSON",
809
+ formElement: "measurement",
810
+ elementMetadata: config,
811
+ ...options,
812
+ });
813
+ },
814
+ object(config, options) {
815
+ const props = "properties" in config ? config.properties : config;
816
+ const properties: ObjectPropertyDef[] = Object.entries(props).map(
817
+ ([name, builder]) => ({
818
+ name,
819
+ field: builder.build(),
820
+ }),
821
+ );
822
+ return createFieldBuilder({
823
+ type: "RAW_JSON",
824
+ formElement: "object",
825
+ elementMetadata: { properties },
826
+ ...options,
827
+ });
828
+ },
829
+ file(config, options) {
830
+ return createFieldBuilder({
831
+ type: "FILES",
832
+ formElement: "formFileValue",
833
+ elementMetadata: config,
834
+ ...options,
835
+ });
836
+ },
837
+ address(config, options) {
838
+ return createFieldBuilder({
839
+ type: "RAW_JSON",
840
+ formElement: "address_group",
841
+ elementMetadata: config,
842
+ ...options,
843
+ });
844
+ },
845
+ geo(options) {
846
+ return createFieldBuilder({
847
+ type: "RAW_JSON",
848
+ formElement: "geo",
849
+ ...options,
850
+ });
851
+ },
852
+ };
853
+ /**
854
+ * Compact alias for `fields`. Same object, shorter name.
855
+ *
856
+ * ```ts
857
+ * import { bd } from "@businessdash/sdk";
858
+ *
859
+ * collection("features", {
860
+ * fields: {
861
+ * name: bd.text().required(),
862
+ * },
863
+ * });
864
+ * ```
865
+ */
866
+ export const bd: Fields = fields;
867
+
868
+ /** Convert a table name to singular/plural names and labels. */
869
+ function parseTableName(tableName: string): {
870
+ nameSingular: string;
871
+ namePlural: string;
872
+ labelSingular: string;
873
+ labelPlural: string;
874
+ labelIdentifierFieldName: string;
875
+ } {
876
+ // For labels, keep underscores so toLabel can convert them properly
877
+ // For names, convert to camelCase (no underscores)
878
+ const singularForLabel = tableName.endsWith("ies")
879
+ ? `${tableName.slice(0, -3)}y`
880
+ : tableName.replace(/_?s$/, "");
881
+ const pluralForLabel = tableName;
882
+
883
+ const toCamelCase = (str: string) =>
884
+ str.replace(/[_-](\w)/g, (_, c) => c.toUpperCase());
885
+
886
+ return {
887
+ nameSingular: toCamelCase(singularForLabel),
888
+ namePlural: toCamelCase(pluralForLabel),
889
+ labelSingular: toLabel(singularForLabel),
890
+ labelPlural: toLabel(pluralForLabel),
891
+ labelIdentifierFieldName: toCamelCase(singularForLabel),
892
+ };
893
+ }
894
+
895
+ /** Collection definition options */
896
+ export interface CollectionOptions<
897
+ F extends Record<string, AnyFieldBuilder> = Record<string, AnyFieldBuilder>,
898
+ > {
899
+ fields: F;
900
+ description?: string;
901
+ icon?: string;
902
+ visibility?: "private" | "authenticated" | "public";
903
+ labelIdentifierFieldName?: string;
904
+ /**
905
+ * Stable identity override. Usually unnecessary because collection()
906
+ * derives a deterministic UUID from tableName.
907
+ */
908
+ universalIdentifier?: string;
909
+ /** Override the primary key strategy for this collection. */
910
+ idType?: PrimaryKeyType;
911
+ /** Row-level security policies for this collection. */
912
+ rlsPolicies?: RlsPolicyConfig[];
913
+ }
914
+
915
+ /** Collection definition returned by collection(). */
916
+ export interface CollectionDefinition<
917
+ F extends Record<string, AnyFieldBuilder> = Record<string, AnyFieldBuilder>,
918
+ > extends ObjectDefinition {
919
+ fields: FieldDefinition[];
920
+ relations?: Array<{
921
+ fieldName: string;
922
+ meta: RelationMetadata;
923
+ }>;
924
+ /** Primary key strategy override for this collection. */
925
+ idType?: PrimaryKeyType;
926
+ /** Row-level security policies. */
927
+ rlsPolicies?: RlsPolicyConfig[];
928
+ /** Phantom type for field inference. */
929
+ __fields?: F;
930
+ }
931
+
932
+ /**
933
+ * Define a collection (table) with a fluent fields API.
934
+ *
935
+ * @example
936
+ * ```ts
937
+ * const FeatureStatus = Enums(["planned", "inProgress", "completed"]);
938
+ *
939
+ * const featureGroups = collection("feature_groups", {
940
+ * fields: {
941
+ * name: fields.text().required(),
942
+ * category_id: fields.reference("categories").required(),
943
+ * status: fields.enum(FeatureStatus).default("planned"),
944
+ * icon: fields.text().optional(),
945
+ * image_url: fields.url().optional(),
946
+ * },
947
+ * });
948
+ * ```
949
+ */
950
+ export function collection<F extends Record<string, AnyFieldBuilder>>(
951
+ tableName: string,
952
+ options: CollectionOptions<F>,
953
+ ): CollectionDefinition<F> {
954
+ const {
955
+ nameSingular,
956
+ namePlural,
957
+ labelSingular,
958
+ labelPlural,
959
+ labelIdentifierFieldName,
960
+ } = parseTableName(tableName);
961
+
962
+ const universalIdentifier =
963
+ options.universalIdentifier ??
964
+ generateUniversalIdentifier(`biab:collection:${nameSingular}`);
965
+
966
+ // Build fields with proper names and labels, collecting relation metadata
967
+ const fieldEntries = Object.entries(options.fields);
968
+ const relations: CollectionDefinition["relations"] = [];
969
+ let fieldIndex = 0;
970
+ const fields: FieldDefinition[] = [];
971
+ for (const [fieldName, builder] of fieldEntries) {
972
+ const relationMeta = getRelationMeta(builder);
973
+ if (relationMeta) {
974
+ relations.push({ fieldName, meta: relationMeta });
975
+ }
976
+ // A many-to-many USED TO be dropped here: the parent got no field at all,
977
+ // on the assumption that the link belonged to a separate join collection
978
+ // the author would build a second form for. That's the leak — it forced
979
+ // everyone to understand join tables to fill in a relationship.
980
+ //
981
+ // It now compiles to a normal RELATION field on the parent (cardinality
982
+ // "many"), so the generator gives it a live multi-picker and the link rows
983
+ // are written from the same form. The link storage is still the universal
984
+ // link table; there is no per-pair collection to name or maintain.
985
+ const fieldDef = builder.build();
986
+ fields.push({
987
+ ...fieldDef,
988
+ universalIdentifier: generateUniversalIdentifier(
989
+ `biab:collection:${nameSingular}:field:${fieldName}`,
990
+ ),
991
+ name: fieldName,
992
+ label: toLabel(fieldName),
993
+ position: fieldIndex,
994
+ });
995
+ fieldIndex++;
996
+ }
997
+
998
+ // Resolve reference targets (collection names -> universalIdentifiers)
999
+ // This would need a registry of all collections to work properly
1000
+ // For now, we store the target collection name and let the consumer resolve it
1001
+
1002
+ return {
1003
+ universalIdentifier,
1004
+ nameSingular,
1005
+ namePlural,
1006
+ labelSingular,
1007
+ labelPlural,
1008
+ labelIdentifierFieldName:
1009
+ options.labelIdentifierFieldName ??
1010
+ (options.fields["name"]
1011
+ ? "name"
1012
+ : (fields[0]?.name ?? labelIdentifierFieldName)),
1013
+ ...(options.icon !== undefined ? { icon: options.icon } : {}),
1014
+ ...(options.description !== undefined
1015
+ ? { description: options.description }
1016
+ : {}),
1017
+ ...(options.visibility !== undefined
1018
+ ? { visibility: options.visibility }
1019
+ : {}),
1020
+ ...(options.idType !== undefined ? { idType: options.idType } : {}),
1021
+ kind: "custom" as ObjectKind,
1022
+ ...(options.rlsPolicies !== undefined
1023
+ ? { rlsPolicies: options.rlsPolicies }
1024
+ : {}),
1025
+ fields,
1026
+ ...(relations.length > 0 ? { relations } : {}),
1027
+ };
1028
+ }
1029
+
1030
+ /**
1031
+ * Polymorphic relations (one field pointing at several object kinds) aren't
1032
+ * accepted yet. Declaring one fails fast here rather than at upload.
1033
+ */
1034
+ export const UNSUPPORTED_FIELD_TYPES: readonly FieldType[] = ["MORPH_RELATION"];
1035
+
1036
+ /** Relation values live in a link table, not in the record's own storage. */
1037
+ export const RELATION_FIELD_TYPES: readonly FieldType[] = [
1038
+ "RELATION",
1039
+ "MORPH_RELATION",
1040
+ ];
1041
+
1042
+ export function isRelationFieldType(type: FieldType): boolean {
1043
+ return RELATION_FIELD_TYPES.includes(type);
1044
+ }
1045
+
1046
+ // ── Enums (SELECT options) ───────────────────────────────────────────
1047
+
1048
+ /**
1049
+ * One value in a SELECT / MULTI_SELECT field — what BIAB calls an "enum".
1050
+ * Not a native Postgres enum: options are metadata, so adding or relabelling
1051
+ * one costs no migration.
1052
+ */
1053
+ export type SelectOption = {
1054
+ /** The value a record actually stores. */
1055
+ value: string;
1056
+ label: string;
1057
+ position: number;
1058
+ color?: string;
1059
+ /**
1060
+ * Stable identity for this option, surviving a `value` rename.
1061
+ *
1062
+ * OPTIONAL, and the reason matters. Records store the option's `value`
1063
+ * string, so renaming `"filed"` → `"submitted"` would otherwise read as
1064
+ * remove-plus-add: a destructive-looking plan for a safe change, and every
1065
+ * row holding the old string quietly fails validation. With an identifier,
1066
+ * the diff sees a rename and backfills the rows.
1067
+ *
1068
+ * Omit it and nothing breaks — the diff falls back to matching on `value`,
1069
+ * which is exactly the pre-existing behavior. Supply it and renames become
1070
+ * safe. That's why this is additive rather than required: no migration is
1071
+ * forced on options that already exist.
1072
+ */
1073
+ universalIdentifier?: string;
1074
+ };
1075
+
1076
+ // ── Fields ───────────────────────────────────────────────────────────
1077
+
1078
+ /** What happens to a record that something else still links to. */
1079
+ export type RelationOnDelete = "restrict" | "setNull" | "cascade";
1080
+
1081
+ /** Delete action modifier for the fluent relation chain. */
1082
+ export type DeleteAction = "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION";
1083
+
1084
+ /** Primary key strategy for auto-injected id fields. */
1085
+ export type PrimaryKeyType = "uuid" | "integer";
1086
+
1087
+ const DELETE_ACTION_TO_ON_DELETE: Record<
1088
+ DeleteAction,
1089
+ RelationOnDelete | undefined
1090
+ > = {
1091
+ CASCADE: "cascade",
1092
+ RESTRICT: "restrict",
1093
+ "SET NULL": "setNull",
1094
+ "NO ACTION": undefined,
1095
+ };
1096
+
1097
+ // ── Form Element Types ───────────────────────────────────────────────
1098
+
1099
+ export type ExtTextType =
1100
+ | "short_text"
1101
+ | "long_text"
1102
+ | "email"
1103
+ | "url"
1104
+ | "rich_text_input"
1105
+ | "dropdown"
1106
+ | "radio_group"
1107
+ | "checkbox_group"
1108
+ | "choice_cards"
1109
+ | "services_selector"
1110
+ | "phone_number"
1111
+ | "date"
1112
+ | "credit_card"
1113
+ | "collection_select"
1114
+ | "timePicker"
1115
+ | "time_picker.date"
1116
+ | "time_picker.time"
1117
+ | "color";
1118
+
1119
+ export type ExtObjectType =
1120
+ | "address_group"
1121
+ | "measurement"
1122
+ | "availability"
1123
+ | "object"
1124
+ | "geo"
1125
+ | "date_range"
1126
+ | "time_picker_timeslot"
1127
+ | "time_picker_datetime";
1128
+
1129
+ export type ExtArrayType =
1130
+ | "multi-select"
1131
+ | "checkbox_group_many"
1132
+ | "choice_cards_many"
1133
+ | "services_selector_many"
1134
+ | "tags"
1135
+ | "formFileValue";
1136
+
1137
+ export type ExtBooleanType = "yes_no_toggle" | "yes_no_checkbox";
1138
+
1139
+ export type ExtNumberType =
1140
+ | "number"
1141
+ | "phone_number_basic"
1142
+ | "currency"
1143
+ | "range"
1144
+ | "star_rating"
1145
+ | "happiness_rating";
1146
+
1147
+ export type AllFormElements =
1148
+ | ExtTextType
1149
+ | ExtObjectType
1150
+ | ExtArrayType
1151
+ | ExtBooleanType
1152
+ | ExtNumberType;
1153
+
1154
+ export type ChoiceCardDef = {
1155
+ name: string;
1156
+ icon?: string;
1157
+ image_url?: string;
1158
+ price?: number;
1159
+ link_url?: string;
1160
+ product_id?: string;
1161
+ description?: string;
1162
+ };
1163
+
1164
+ export type RangeDef = {
1165
+ min: number;
1166
+ max: number;
1167
+ step?: number;
1168
+ unit: "number" | "percentage" | "currency";
1169
+ };
1170
+
1171
+ // ── Measurement Types ────────────────────────────────────────────────
1172
+
1173
+ export type MeasurementCategory =
1174
+ | "sound"
1175
+ | "pressure"
1176
+ | "torque"
1177
+ | "speed"
1178
+ | "time"
1179
+ | "area"
1180
+ | "volume"
1181
+ | "power"
1182
+ | "energy"
1183
+ | "storage"
1184
+ | "storageSpeed"
1185
+ | "frequency"
1186
+ | "temperature"
1187
+ | "mass"
1188
+ | "length"
1189
+ | "currency"
1190
+ | (string & {});
1191
+
1192
+ export const PREDEFINED_UNITS: Record<string, readonly string[]> = {
1193
+ sound: ["dB"],
1194
+ pressure: ["Pa", "kPa", "bar", "psi", "atm", "mmHg"],
1195
+ torque: ["in-lb", "N-m", "ft-lb", "kgf-m"],
1196
+ speed: ["m/s", "km/h", "mph", "ft/s", "kn", "ly/yr", "c", "Mach"],
1197
+ time: ["ms", "s", "min", "h", "day", "wk", "yr"],
1198
+ area: ["m2", "km2", "ft2", "ac", "ha", "mi2"],
1199
+ volume: ["mL", "L", "m3", "fl oz", "cup", "pt", "qt", "gal"],
1200
+ power: ["W", "kW", "MW", "GW", "hp"],
1201
+ energy: ["J", "kJ", "Wh", "kWh", "MWh", "cal", "kcal"],
1202
+ storage: ["B", "KB", "MB", "GB", "TB", "PB", "KiB", "MiB", "GiB", "TiB"],
1203
+ storageSpeed: ["MHz", "MT/s", "GB/s", "MB/s", "IOPS", "GT/s"],
1204
+ frequency: ["Hz", "kHz", "MHz", "GHz", "THz"],
1205
+ temperature: ["C", "F", "K"],
1206
+ mass: ["mg", "g", "kg", "t", "oz", "lb", "st"],
1207
+ length: ["nm", "µm", "mm", "cm", "m", "km", "in", "ft", "yd", "mi"],
1208
+ currency: ["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR"],
1209
+ } as const;
1210
+
1211
+ export type MeasurementDef = {
1212
+ category: MeasurementCategory;
1213
+ unitPicker: "fixed" | "userDefined";
1214
+ default_unit: string;
1215
+ resolve_to_your_unit: string;
1216
+ allow_decimals: boolean;
1217
+ };
1218
+
1219
+ // ── Object Field Types ───────────────────────────────────────────────
1220
+
1221
+ export type ObjectPropertyDef = {
1222
+ name: string;
1223
+ label?: string;
1224
+ field: FieldDefinition;
1225
+ };
1226
+
1227
+ export type ObjectSchemaDef = {
1228
+ properties: ObjectPropertyDef[];
1229
+ strategy?: StorageStrategy;
1230
+ separateTableName?: string;
1231
+ };
1232
+
1233
+ // ── Dynamic Options Types ────────────────────────────────────────────
1234
+
1235
+ export type DynamicOptionsDef = {
1236
+ endpoint: string;
1237
+ cachePolicy?: "stale-while-revalidate" | "no-cache" | "cache-first";
1238
+ method?: "GET" | "POST";
1239
+ headers?: Record<string, string>;
1240
+ };
1241
+
1242
+ // ── Storage / Encryption / RLS Types ─────────────────────────────────
1243
+
1244
+ export type StorageStrategy = "flattened" | "jsonb" | "separateTable";
1245
+
1246
+ export type EncryptionMode = "none" | "user" | "master";
1247
+
1248
+ export type RlsPolicyConfig = {
1249
+ roles: ("authenticated" | "anonymous" | "admin" | string)[];
1250
+ actions: ("read" | "write" | "update" | "delete")[];
1251
+ expression?: string;
1252
+ };
1253
+
1254
+ // ── Conditional Validation Types ─────────────────────────────────────
1255
+
1256
+ export type RequiredIfCondition = {
1257
+ fieldRef: string;
1258
+ op: "equals" | "notEquals";
1259
+ values: unknown[];
1260
+ };
1261
+
1262
+ // ── File Field Types ─────────────────────────────────────────────────
1263
+
1264
+ export type FileDef = {
1265
+ max_size_mb?: number;
1266
+ allowed_mimetypes?: string[];
1267
+ };
1268
+
1269
+ // ── Address Field Types ──────────────────────────────────────────────
1270
+
1271
+ export type AddressDef = {
1272
+ require_postal_code?: boolean;
1273
+ restrict_countries?: string[];
1274
+ };
1275
+
1276
+ export type ElementMetadata =
1277
+ | ChoiceCardDef[]
1278
+ | RangeDef
1279
+ | MeasurementDef
1280
+ | ObjectSchemaDef
1281
+ | FileDef
1282
+ | AddressDef
1283
+ | undefined;
1284
+
1285
+ // ── Validation Rules ─────────────────────────────────────────────────
1286
+
1287
+ export type SpecialDateValue = "today()" | string | Date;
1288
+
1289
+ export interface ValidationRules {
1290
+ lessThan?: number;
1291
+ greaterThan?: number;
1292
+ lessThanToday?: boolean;
1293
+ greaterThanToday?: boolean;
1294
+ specificDateValue?: SpecialDateValue;
1295
+ minLength?: number;
1296
+ maxLength?: number;
1297
+ floor?: boolean;
1298
+ isDecimal?: boolean;
1299
+ round?: number;
1300
+ positive?: boolean;
1301
+ notNull?: boolean;
1302
+ requiredIf?: RequiredIfCondition;
1303
+ regex?: RegExp | string;
1304
+ checkExpression?: string;
1305
+ }
1306
+
1307
+ /** Runtime field value validator using ValidationRules. */
1308
+ export function validateFieldValue(
1309
+ value: unknown,
1310
+ rules: ValidationRules,
1311
+ context?: Record<string, unknown>,
1312
+ ): { valid: boolean; value: unknown; error?: string } {
1313
+ if (value === undefined || value === null || value === "") {
1314
+ if (rules.notNull) {
1315
+ return {
1316
+ valid: false,
1317
+ value,
1318
+ error: "Value is required and cannot be null or empty.",
1319
+ };
1320
+ }
1321
+ if (rules.requiredIf && context) {
1322
+ const refValue = context[rules.requiredIf.fieldRef];
1323
+ const conditionMet =
1324
+ rules.requiredIf.op === "equals"
1325
+ ? rules.requiredIf.values.includes(refValue)
1326
+ : !rules.requiredIf.values.includes(refValue);
1327
+ if (conditionMet) {
1328
+ return {
1329
+ valid: false,
1330
+ value,
1331
+ error: `This field is required when "${rules.requiredIf.fieldRef}" ${rules.requiredIf.op === "equals" ? "equals" : "does not equal"} one of [${rules.requiredIf.values.join(", ")}].`,
1332
+ };
1333
+ }
1334
+ }
1335
+ return { valid: true, value: null };
1336
+ }
1337
+
1338
+ // String / array length checks — also return early for plain strings/arrays
1339
+ // when no date rule applies (date values are strings that need further checks).
1340
+ if (typeof value === "string" || Array.isArray(value)) {
1341
+ const len = (value as string | unknown[]).length;
1342
+ if (rules.minLength !== undefined && len < rules.minLength) {
1343
+ return {
1344
+ valid: false,
1345
+ value,
1346
+ error: `Must contain at least ${rules.minLength} entries or characters.`,
1347
+ };
1348
+ }
1349
+ if (rules.maxLength !== undefined && len > rules.maxLength) {
1350
+ return {
1351
+ valid: false,
1352
+ value,
1353
+ error: `Must contain no more than ${rules.maxLength} entries or characters.`,
1354
+ };
1355
+ }
1356
+ if (rules.regex && typeof value === "string") {
1357
+ const pattern =
1358
+ typeof rules.regex === "string" ? new RegExp(rules.regex) : rules.regex;
1359
+ if (!pattern.test(value)) {
1360
+ return {
1361
+ valid: false,
1362
+ value,
1363
+ error: `Value does not match the required pattern.`,
1364
+ };
1365
+ }
1366
+ }
1367
+ const hasDateRule =
1368
+ rules.lessThanToday || rules.greaterThanToday || rules.specificDateValue;
1369
+ if (!hasDateRule) {
1370
+ return { valid: true, value };
1371
+ }
1372
+ }
1373
+
1374
+ // Date checks — must run before numeric NaN short-circuit (date strings
1375
+ // like "2000-01-01" are NaN when cast to Number but still need validation).
1376
+ if (
1377
+ rules.lessThanToday ||
1378
+ rules.greaterThanToday ||
1379
+ rules.specificDateValue
1380
+ ) {
1381
+ if (typeof value !== "string" && typeof value !== "number") {
1382
+ return { valid: false, value, error: "Value is not a valid date." };
1383
+ }
1384
+ const targetTime = new Date(value).getTime();
1385
+ if (Number.isNaN(targetTime)) {
1386
+ return { valid: false, value, error: "Value is not a valid date." };
1387
+ }
1388
+ const now = new Date();
1389
+ const todayStart = new Date(
1390
+ now.getFullYear(),
1391
+ now.getMonth(),
1392
+ now.getDate(),
1393
+ ).getTime();
1394
+
1395
+ if (rules.lessThanToday && targetTime >= todayStart) {
1396
+ return {
1397
+ valid: false,
1398
+ value,
1399
+ error: "Date must be historical (less than today).",
1400
+ };
1401
+ }
1402
+ if (rules.greaterThanToday && targetTime <= todayStart) {
1403
+ return {
1404
+ valid: false,
1405
+ value,
1406
+ error: "Date must be a future target (greater than today).",
1407
+ };
1408
+ }
1409
+ if (rules.specificDateValue === "today()" && targetTime !== todayStart) {
1410
+ return {
1411
+ valid: false,
1412
+ value,
1413
+ error: "Date value must map exactly to today.",
1414
+ };
1415
+ }
1416
+ return { valid: true, value };
1417
+ }
1418
+
1419
+ // Numeric checks
1420
+ const numericValue = Number(value);
1421
+ if (Number.isNaN(numericValue)) {
1422
+ return { valid: true, value };
1423
+ }
1424
+
1425
+ if (rules.positive && numericValue < 0) {
1426
+ return {
1427
+ valid: false,
1428
+ value: numericValue,
1429
+ error: "Value must be a positive number (greater than or equal to 0).",
1430
+ };
1431
+ }
1432
+
1433
+ if (rules.lessThan !== undefined && numericValue >= rules.lessThan) {
1434
+ return {
1435
+ valid: false,
1436
+ value: numericValue,
1437
+ error: `Value must be strictly less than ${rules.lessThan}.`,
1438
+ };
1439
+ }
1440
+ if (rules.greaterThan !== undefined && numericValue <= rules.greaterThan) {
1441
+ return {
1442
+ valid: false,
1443
+ value: numericValue,
1444
+ error: `Value must be strictly greater than ${rules.greaterThan}.`,
1445
+ };
1446
+ }
1447
+
1448
+ if (rules.isDecimal && numericValue % 1 === 0) {
1449
+ return {
1450
+ valid: false,
1451
+ value: numericValue,
1452
+ error: "Value must contain an active decimal fractional component.",
1453
+ };
1454
+ }
1455
+
1456
+ // Transform: apply rounding or floor
1457
+ let transformed = numericValue;
1458
+ if (rules.round !== undefined) {
1459
+ const factor = 10 ** rules.round;
1460
+ transformed = Math.round(transformed * factor) / factor;
1461
+ } else if (rules.floor) {
1462
+ transformed = Math.floor(transformed);
1463
+ }
1464
+
1465
+ if (transformed !== numericValue) {
1466
+ return { valid: true, value: transformed };
1467
+ }
1468
+
1469
+ return { valid: true, value: numericValue };
1470
+ }
1471
+
1472
+ /** Runtime measurement value validator. */
1473
+ export function validateAndResolveMeasurement(
1474
+ input: { value: unknown; unit: string },
1475
+ meta: MeasurementDef,
1476
+ ): { valid: boolean; value?: { value: number; unit: string }; error?: string } {
1477
+ if (input.value === undefined || input.value === null) {
1478
+ return { valid: true };
1479
+ }
1480
+
1481
+ const numValue = Number(input.value);
1482
+ if (Number.isNaN(numValue)) {
1483
+ return { valid: false, error: "Measurement value must be a valid number." };
1484
+ }
1485
+
1486
+ if (!meta.allow_decimals && numValue % 1 !== 0) {
1487
+ return {
1488
+ valid: false,
1489
+ error: `Decimals are not allowed for measurement category "${meta.category}".`,
1490
+ };
1491
+ }
1492
+
1493
+ if (meta.unitPicker === "fixed" && meta.category in PREDEFINED_UNITS) {
1494
+ const allowed = PREDEFINED_UNITS[meta.category];
1495
+ if (allowed && !allowed.includes(input.unit)) {
1496
+ return {
1497
+ valid: false,
1498
+ error: `Unit "${input.unit}" is not in the fixed set [${allowed.join(", ")}] for category "${meta.category}".`,
1499
+ };
1500
+ }
1501
+ }
1502
+
1503
+ let finalValue = numValue;
1504
+ if (meta.resolve_to_your_unit && input.unit !== meta.resolve_to_your_unit) {
1505
+ finalValue = numValue;
1506
+ }
1507
+
1508
+ return {
1509
+ valid: true,
1510
+ value: { value: finalValue, unit: meta.resolve_to_your_unit || input.unit },
1511
+ };
1512
+ }
1513
+
1514
+ /** Compatibility map: FieldType → valid form element names. */
1515
+ const FIELD_FORM_ELEMENT_MAP: Partial<Record<FieldType, AllFormElements[]>> = {
1516
+ TEXT: [
1517
+ "short_text",
1518
+ "long_text",
1519
+ "email",
1520
+ "url",
1521
+ "rich_text_input",
1522
+ "dropdown",
1523
+ "radio_group",
1524
+ "choice_cards",
1525
+ "services_selector",
1526
+ "phone_number",
1527
+ "date",
1528
+ "credit_card",
1529
+ "collection_select",
1530
+ "timePicker",
1531
+ "time_picker.date",
1532
+ "time_picker.time",
1533
+ "color",
1534
+ ],
1535
+ NUMBER: [
1536
+ "number",
1537
+ "currency",
1538
+ "range",
1539
+ "star_rating",
1540
+ "happiness_rating",
1541
+ "phone_number_basic",
1542
+ ],
1543
+ NUMERIC: [
1544
+ "number",
1545
+ "currency",
1546
+ "range",
1547
+ "star_rating",
1548
+ "happiness_rating",
1549
+ "phone_number_basic",
1550
+ ],
1551
+ BOOLEAN: ["yes_no_toggle", "yes_no_checkbox"],
1552
+ DATE: ["date", "date_range"],
1553
+ DATE_TIME: [
1554
+ "timePicker",
1555
+ "time_picker.date",
1556
+ "time_picker.time",
1557
+ "time_picker_timeslot",
1558
+ "time_picker_datetime",
1559
+ "date_range",
1560
+ ],
1561
+ UUID: ["short_text"],
1562
+ SELECT: ["dropdown", "radio_group"],
1563
+ MULTI_SELECT: ["multi-select", "checkbox_group", "tags"],
1564
+ RELATION: ["collection_select"],
1565
+ RICH_TEXT: ["rich_text_input"],
1566
+ RAW_JSON: [
1567
+ "address_group",
1568
+ "measurement",
1569
+ "availability",
1570
+ "date_range",
1571
+ "object",
1572
+ "geo",
1573
+ "time_picker_timeslot",
1574
+ "time_picker_datetime",
1575
+ ],
1576
+ ARRAY: [
1577
+ "multi-select",
1578
+ "checkbox_group_many",
1579
+ "choice_cards_many",
1580
+ "services_selector_many",
1581
+ "tags",
1582
+ "formFileValue",
1583
+ ],
1584
+ EMAILS: ["email"],
1585
+ PHONES: ["phone_number", "phone_number_basic"],
1586
+ RATING: ["star_rating", "happiness_rating"],
1587
+ CURRENCY: ["currency"],
1588
+ FILES: ["formFileValue"],
1589
+ };
1590
+
1591
+ function getRecommendedFormElements(fieldType: FieldType): AllFormElements[] {
1592
+ return FIELD_FORM_ELEMENT_MAP[fieldType] ?? [];
1593
+ }
1594
+
1595
+ function isValidFormElement(
1596
+ fieldType: FieldType,
1597
+ elementName: string,
1598
+ ): boolean {
1599
+ const allowed = FIELD_FORM_ELEMENT_MAP[fieldType];
1600
+ if (!allowed) return false;
1601
+ return allowed.some((e) => e === elementName);
1602
+ }
1603
+
1604
+ export type FieldDefinition = {
1605
+ /** Stable identity. Survives renames — the diff keys on this, never on `name`. */
1606
+ universalIdentifier: string;
1607
+ /** camelCase. Doubles as the storage key, so renaming it backfills every row. */
1608
+ name: string;
1609
+ label: string;
1610
+ type: FieldType;
1611
+ icon?: string;
1612
+ description?: string;
1613
+ isRequired?: boolean;
1614
+ isNullable?: boolean;
1615
+ isUnique?: boolean;
1616
+ defaultValue?: unknown;
1617
+ isIndexed?: boolean;
1618
+ isPrimaryKey?: boolean;
1619
+ isAutoIncrement?: boolean;
1620
+ position?: number;
1621
+ /** Explicitly chosen form element for rendering. */
1622
+ formElement?: AllFormElements;
1623
+ /** Metadata payload for complex form elements (choice_cards options, range bounds). */
1624
+ elementMetadata?: ElementMetadata;
1625
+ /** Auto-populated list of compatible form elements for this field type. */
1626
+ recommendedFormElements?: AllFormElements[];
1627
+ /** Validation constraints for this field. */
1628
+ validation?: ValidationRules;
1629
+ /** Inline helper/instruction text displayed next to the field. */
1630
+ helperText?: string;
1631
+ /** Dynamic options endpoint for dropdown/radio/etc. */
1632
+ dynamicOptions?: DynamicOptionsDef;
1633
+ options?: SelectOption[];
1634
+ relationTarget?: string;
1635
+ onDelete?: RelationOnDelete;
1636
+ /**
1637
+ * RELATION only — how many targets one source record may link to.
1638
+ * `"one"` makes this a single-value reference (a new pick replaces the old);
1639
+ * `"many"` (the default, and what `belongsToMany` sets) is a join table.
1640
+ */
1641
+ relationCardinality?: "one" | "many";
1642
+ /**
1643
+ * RELATION + `many` only — typed payload carried ON EACH LINK, compiled from
1644
+ * `belongsToMany(..., { fields })`. This is what turns a bare pair of ids into
1645
+ * a real join table: "which technologies, and at what proficiency".
1646
+ */
1647
+ linkAttributes?: LinkAttributeDefinition[];
1648
+ /** Storage strategy for object fields. */
1649
+ strategy?: StorageStrategy;
1650
+ /** Custom side-table name for separateTable strategy. */
1651
+ separateTableName?: string;
1652
+ /** Field-level encryption mode. */
1653
+ encryption?: EncryptionMode;
1654
+ /** Strip from public API responses. */
1655
+ isPrivate?: boolean;
1656
+ };
1657
+
1658
+ /** One typed attribute carried on a many-to-many link row. Mirrors
1659
+ * `LinkAttributeDef` on the BIAB side (`src/shared/data-model/link-attributes.ts`). */
1660
+ export type LinkAttributeDefinition = {
1661
+ name: string;
1662
+ label: string;
1663
+ type: "TEXT" | "NUMBER" | "BOOLEAN" | "SELECT";
1664
+ options?: string[];
1665
+ isRequired?: boolean;
1666
+ description?: string;
1667
+ };
1668
+
1669
+ const NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
1670
+
1671
+ function assertSelectOptions(def: FieldDefinition): void {
1672
+ const isSelect = def.type === "SELECT" || def.type === "MULTI_SELECT";
1673
+ if (!isSelect) {
1674
+ if (def.options && def.options.length > 0) {
1675
+ throw new Error(
1676
+ `defineField: "${def.name}" is a ${def.type} field but declares options — options belong to SELECT / MULTI_SELECT.`,
1677
+ );
1678
+ }
1679
+ return;
1680
+ }
1681
+ if (!def.options || def.options.length === 0) {
1682
+ throw new Error(
1683
+ `defineField: ${def.type} field "${def.name}" requires at least one option.`,
1684
+ );
1685
+ }
1686
+
1687
+ const values = new Set<string>();
1688
+ const identifiers = new Set<string>();
1689
+ for (const option of def.options) {
1690
+ if (!option.value) {
1691
+ throw new Error(
1692
+ `defineField: ${def.type} field "${def.name}" has an option with an empty value.`,
1693
+ );
1694
+ }
1695
+ if (values.has(option.value)) {
1696
+ throw new Error(
1697
+ `defineField: ${def.type} field "${def.name}" has duplicate option value "${option.value}".`,
1698
+ );
1699
+ }
1700
+ values.add(option.value);
1701
+
1702
+ // Identity is optional, but a duplicated one is always a copy-paste
1703
+ // bug — it would make two options indistinguishable to the diff.
1704
+ if (option.universalIdentifier) {
1705
+ if (identifiers.has(option.universalIdentifier)) {
1706
+ throw new Error(
1707
+ `defineField: ${def.type} field "${def.name}" reuses option universalIdentifier "${option.universalIdentifier}" — each option needs its own.`,
1708
+ );
1709
+ }
1710
+ identifiers.add(option.universalIdentifier);
1711
+ }
1712
+ }
1713
+ }
1714
+
1715
+ function assertRelation(def: FieldDefinition): void {
1716
+ if (def.type === "RELATION") {
1717
+ if (!def.relationTarget) {
1718
+ throw new Error(
1719
+ `defineField: RELATION field "${def.name}" requires relationTarget (the universalIdentifier of the object it points at).`,
1720
+ );
1721
+ }
1722
+ return;
1723
+ }
1724
+ if (def.relationTarget) {
1725
+ throw new Error(
1726
+ `defineField: "${def.name}" is a ${def.type} field but declares relationTarget — that belongs to RELATION fields.`,
1727
+ );
1728
+ }
1729
+ if (def.onDelete) {
1730
+ throw new Error(
1731
+ `defineField: "${def.name}" is a ${def.type} field but declares onDelete — that belongs to RELATION fields.`,
1732
+ );
1733
+ }
1734
+ }
1735
+
1736
+ function internalDefineField<T extends FieldDefinition>(
1737
+ definition: T,
1738
+ ): Readonly<T> {
1739
+ if (!definition.universalIdentifier) {
1740
+ throw new Error("defineCollection: universalIdentifier is required.");
1741
+ }
1742
+ if (!definition.name || !NAME_PATTERN.test(definition.name)) {
1743
+ throw new Error(
1744
+ `defineCollection: name "${definition.name}" must be camelCase, starting with a lowercase letter.`,
1745
+ );
1746
+ }
1747
+ if (!definition.label) {
1748
+ throw new Error(`defineCollection: "${definition.name}" requires a label.`);
1749
+ }
1750
+ if (!isFieldType(definition.type)) {
1751
+ throw new Error(
1752
+ `defineCollection: unknown field type "${definition.type}" on "${definition.name}".`,
1753
+ );
1754
+ }
1755
+ if (UNSUPPORTED_FIELD_TYPES.includes(definition.type)) {
1756
+ throw new Error(
1757
+ `defineCollection: field type ${definition.type} isn't supported yet (on "${definition.name}").`,
1758
+ );
1759
+ }
1760
+ assertSelectOptions(definition);
1761
+ assertRelation(definition);
1762
+ return Object.freeze(definition);
1763
+ }
1764
+
1765
+ // ── Objects & Collections ────────────────────────────────────────────
1766
+
1767
+ export type ObjectKind = "standard" | "custom";
1768
+
1769
+ /**
1770
+ * Who may read an object's rows from outside your server.
1771
+ *
1772
+ * - `private` (default) — your server only, with a secret key. Nothing on the
1773
+ * public internet can read it.
1774
+ * - `authenticated` — any customer signed in to your site, via tenant auth.
1775
+ * - `public` — anyone at all, with a publishable (`pk_…`) key.
1776
+ *
1777
+ * ⚠️ `authenticated` means **any signed-in customer can read every row of this
1778
+ * object**. It does NOT scope rows to the customer they belong to — that's
1779
+ * row-level ownership, which this flag does not do. Never use `authenticated`
1780
+ * to keep one customer's data away from another's.
1781
+ *
1782
+ * Applies to reads from outside only. Your dashboard always sees everything.
1783
+ */
1784
+ export type ObjectVisibility = "private" | "authenticated" | "public";
1785
+
1786
+ export const objectVisibilityValues: readonly ObjectVisibility[] = [
1787
+ "private",
1788
+ "authenticated",
1789
+ "public",
1790
+ ];
1791
+
1792
+ export type ObjectDefinition = {
1793
+ /** Stable identity. Survives renames. */
1794
+ universalIdentifier: string;
1795
+ /** `custom` (a new table) or `standard` (custom fields on a BIAB entity). */
1796
+ kind?: ObjectKind;
1797
+ /**
1798
+ * Who may read these rows from outside. Defaults to `private`.
1799
+ *
1800
+ * Declaring it here makes exposure reviewable in a pull request instead of
1801
+ * a setting someone flipped once. It still only PROPOSES: like every other
1802
+ * part of the model, a human promotes it before it takes effect.
1803
+ *
1804
+ * See {@link ObjectVisibility} — especially what `authenticated` does not do.
1805
+ */
1806
+ visibility?: ObjectVisibility;
1807
+ nameSingular: string;
1808
+ namePlural: string;
1809
+ labelSingular: string;
1810
+ labelPlural: string;
1811
+ icon?: string;
1812
+ description?: string;
1813
+ /** Which field is a record's headline. Must be one of `fields`. */
1814
+ labelIdentifierFieldName?: string;
1815
+ /**
1816
+ * `kind: "standard"` only — the BIAB entity these fields attach to
1817
+ * (e.g. "contact"). The object's own system fields stay immutable from
1818
+ * the CLI; only custom fields are yours to declare.
1819
+ */
1820
+ standardEntityName?: string;
1821
+ /** Which feature owns this table (set via SchemaBoundary). */
1822
+ featureId?: string;
1823
+ fields: FieldDefinition[];
1824
+ };
1825
+
1826
+ function internalDefineObject<T extends ObjectDefinition>(
1827
+ definition: T,
1828
+ ): Readonly<T & { kind: ObjectKind }> {
1829
+ if (!definition.universalIdentifier) {
1830
+ throw new Error("defineField: universalIdentifier is required.");
1831
+ }
1832
+ for (const key of ["nameSingular", "namePlural"] as const) {
1833
+ const value = definition[key];
1834
+ if (!value || !NAME_PATTERN.test(value)) {
1835
+ throw new Error(
1836
+ `defineField: ${key} "${value}" must be camelCase, starting with a lowercase letter.`,
1837
+ );
1838
+ }
1839
+ }
1840
+ if (definition.nameSingular === definition.namePlural) {
1841
+ throw new Error(
1842
+ `defineField: nameSingular and namePlural are both "${definition.nameSingular}" — they must differ, since routes and views derive from them.`,
1843
+ );
1844
+ }
1845
+ if (!definition.labelSingular || !definition.labelPlural) {
1846
+ throw new Error(
1847
+ `defineField: "${definition.nameSingular}" requires labelSingular and labelPlural.`,
1848
+ );
1849
+ }
1850
+
1851
+ if (
1852
+ definition.visibility !== undefined &&
1853
+ !objectVisibilityValues.includes(definition.visibility)
1854
+ ) {
1855
+ throw new Error(
1856
+ `defineField: "${definition.nameSingular}" has visibility "${definition.visibility}" — must be one of ${objectVisibilityValues.join(", ")}.`,
1857
+ );
1858
+ }
1859
+
1860
+ const kind = definition.kind ?? "custom";
1861
+ if (kind === "standard" && !definition.standardEntityName) {
1862
+ throw new Error(
1863
+ `defineField: standard object "${definition.nameSingular}" requires standardEntityName.`,
1864
+ );
1865
+ }
1866
+ if (kind === "custom" && definition.standardEntityName) {
1867
+ throw new Error(
1868
+ `defineField: custom object "${definition.nameSingular}" must not set standardEntityName.`,
1869
+ );
1870
+ }
1871
+ if (definition.fields.length === 0) {
1872
+ throw new Error(
1873
+ `defineField: "${definition.nameSingular}" declares no fields.`,
1874
+ );
1875
+ }
1876
+
1877
+ const names = new Set<string>();
1878
+ const identifiers = new Set<string>();
1879
+ for (const field of definition.fields) {
1880
+ if (names.has(field.name)) {
1881
+ throw new Error(
1882
+ `defineField: duplicate field name "${field.name}" on "${definition.nameSingular}".`,
1883
+ );
1884
+ }
1885
+ names.add(field.name);
1886
+ if (identifiers.has(field.universalIdentifier)) {
1887
+ throw new Error(
1888
+ `defineField: duplicate field universalIdentifier "${field.universalIdentifier}" on "${definition.nameSingular}".`,
1889
+ );
1890
+ }
1891
+ identifiers.add(field.universalIdentifier);
1892
+ }
1893
+
1894
+ if (
1895
+ definition.labelIdentifierFieldName &&
1896
+ !names.has(definition.labelIdentifierFieldName)
1897
+ ) {
1898
+ throw new Error(
1899
+ `defineField: labelIdentifierFieldName "${definition.labelIdentifierFieldName}" isn't a field on "${definition.nameSingular}".`,
1900
+ );
1901
+ }
1902
+
1903
+ return Object.freeze({ ...definition, kind });
1904
+ }
1905
+
1906
+ /**
1907
+ * The dispatch rule both `defineCollection` and `defineField` run on: an
1908
+ * OBJECT definition names itself and carries fields; a FIELD definition does
1909
+ * neither. Named once as a predicate so TypeScript narrows the union instead
1910
+ * of the implementations falling back to `any` — and so the two entry points
1911
+ * cannot drift apart on what "is an object" means.
1912
+ *
1913
+ * Stays defensive about non-objects: these are public entry points and
1914
+ * consumers pass what they pass.
1915
+ */
1916
+ function isObjectDefinitionInput(
1917
+ definition: FieldDefinition | ObjectDefinition,
1918
+ ): definition is ObjectDefinition {
1919
+ return (
1920
+ typeof definition === "object" &&
1921
+ definition !== null &&
1922
+ ("nameSingular" in definition || "fields" in definition)
1923
+ );
1924
+ }
1925
+
1926
+ export function defineCollection<T extends FieldDefinition>(
1927
+ definition: T,
1928
+ ): Readonly<T>;
1929
+ export function defineCollection<T extends ObjectDefinition>(
1930
+ definition: T,
1931
+ ): Readonly<T & { kind: ObjectKind }>;
1932
+ export function defineCollection(
1933
+ definition: FieldDefinition | ObjectDefinition,
1934
+ ):
1935
+ | Readonly<FieldDefinition>
1936
+ | Readonly<ObjectDefinition & { kind: ObjectKind }> {
1937
+ if (isObjectDefinitionInput(definition)) {
1938
+ return internalDefineObject(definition);
1939
+ }
1940
+ return internalDefineField(definition);
1941
+ }
1942
+
1943
+ export function defineField<T extends ObjectDefinition>(
1944
+ definition: T,
1945
+ ): Readonly<T & { kind: ObjectKind }>;
1946
+ export function defineField<T extends FieldDefinition>(
1947
+ definition: T,
1948
+ ): Readonly<T>;
1949
+ export function defineField(
1950
+ definition: FieldDefinition | ObjectDefinition,
1951
+ ):
1952
+ | Readonly<FieldDefinition>
1953
+ | Readonly<ObjectDefinition & { kind: ObjectKind }> {
1954
+ if (isObjectDefinitionInput(definition)) {
1955
+ return internalDefineObject(definition);
1956
+ }
1957
+ console.warn(
1958
+ "[@businessdash/sdk] Using defineField for column/field definitions is deprecated. Please use defineCollection for field definitions instead, and defineField (or defineCollection) for table/collection definitions.",
1959
+ );
1960
+ return internalDefineField(definition);
1961
+ }
1962
+
1963
+ /**
1964
+ * @deprecated Use `defineField` (or `defineCollection`) instead. `defineObject` will be removed in a future release.
1965
+ */
1966
+ export function defineObject<T extends ObjectDefinition>(
1967
+ definition: T,
1968
+ ): Readonly<T & { kind: ObjectKind }> {
1969
+ console.warn(
1970
+ "[@businessdash/sdk] defineObject is deprecated. Please use defineField (or defineCollection) instead.",
1971
+ );
1972
+ return internalDefineObject(definition);
1973
+ }
1974
+
1975
+ // ── The model ────────────────────────────────────────────────────────
1976
+
1977
+ export type DataModelInput = {
1978
+ objects?: ObjectDefinition[];
1979
+ collections?: ObjectDefinition[];
1980
+ /** Free-form provenance carried into the artifact (git sha, app name, …). */
1981
+ metadata?: Record<string, string | number | boolean>;
1982
+ };
1983
+
1984
+ export type DataModelArtifact = {
1985
+ $schema: "https://biab.app/schemas/data-model/v1";
1986
+ version: 1;
1987
+ metadata: Record<string, string | number | boolean>;
1988
+ objects: Array<{
1989
+ universalIdentifier: string;
1990
+ kind: ObjectKind;
1991
+ nameSingular: string;
1992
+ namePlural: string;
1993
+ labelSingular: string;
1994
+ labelPlural: string;
1995
+ /**
1996
+ * Omitted entirely when private — see the note in `build()`. Absent
1997
+ * means private; the two are the same model and must hash the same.
1998
+ */
1999
+ visibility?: ObjectVisibility;
2000
+ icon?: string;
2001
+ description?: string;
2002
+ labelIdentifierFieldName?: string;
2003
+ standardEntityName?: string;
2004
+ fields: Array<{
2005
+ universalIdentifier: string;
2006
+ name: string;
2007
+ label: string;
2008
+ type: FieldType;
2009
+ icon?: string;
2010
+ description?: string;
2011
+ isNullable?: boolean;
2012
+ isUnique?: boolean;
2013
+ isIndexed?: boolean;
2014
+ isPrimaryKey?: boolean;
2015
+ isAutoIncrement?: boolean;
2016
+ formElement?: AllFormElements;
2017
+ elementMetadata?: ElementMetadata;
2018
+ recommendedFormElements?: AllFormElements[];
2019
+ validation?: ValidationRules;
2020
+ helperText?: string;
2021
+ defaultValue?: unknown;
2022
+ position?: number;
2023
+ options?: SelectOption[];
2024
+ relationTarget?: string;
2025
+ onDelete?: RelationOnDelete;
2026
+ strategy?: StorageStrategy;
2027
+ separateTableName?: string;
2028
+ }>;
2029
+ }>;
2030
+ };
2031
+
2032
+ export type DataModelClientShape = {
2033
+ readonly objects: readonly Readonly<ObjectDefinition>[];
2034
+ /** The document to upload. */
2035
+ toJSONSchema(): DataModelArtifact;
2036
+ /** SHA-256 of the canonicalized artifact. The server recomputes and compares. */
2037
+ checksum(): Promise<string>;
2038
+ };
2039
+
2040
+ /**
2041
+ * Sort keys recursively so two functionally identical models hash the same.
2042
+ * Must mirror the server's canonicalization byte-for-byte, or every push
2043
+ * fails the checksum check.
2044
+ *
2045
+ * Arrays keep their order on purpose: an option list's order is meaningful
2046
+ * (it's the render order), so `[draft, filed]` is NOT `[filed, draft]`.
2047
+ */
2048
+ function canonicalize(value: unknown): unknown {
2049
+ if (value === null || typeof value !== "object") return value;
2050
+ if (Array.isArray(value)) return value.map(canonicalize);
2051
+ const out: Record<string, unknown> = {};
2052
+ for (const k of Object.keys(value as Record<string, unknown>).sort()) {
2053
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
2054
+ out[k] = canonicalize((value as Record<string, unknown>)[k]);
2055
+ }
2056
+ return out;
2057
+ }
2058
+
2059
+ async function sha256Hex(input: string): Promise<string> {
2060
+ const buf = new TextEncoder().encode(input);
2061
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buf);
2062
+ return Array.from(new Uint8Array(digest))
2063
+ .map((b) => b.toString(16).padStart(2, "0"))
2064
+ .join("");
2065
+ }
2066
+
2067
+ export async function computeDataModelChecksum(
2068
+ body: Record<string, unknown>,
2069
+ ): Promise<string> {
2070
+ return sha256Hex(JSON.stringify(canonicalize(body)));
2071
+ }
2072
+
2073
+ /** Global options for createSchema(). */
2074
+ export interface SchemaOptions {
2075
+ /** Default primary key strategy across all collections. Defaults to 'uuid'. */
2076
+ defaultIdType?: PrimaryKeyType;
2077
+ }
2078
+
2079
+ /**
2080
+ * Declare the org's custom database schema. Default-export the result from
2081
+ * `biab.data-model.config.ts`.
2082
+ *
2083
+ * ```ts
2084
+ * export default createSchema({
2085
+ * collections: [
2086
+ * defineField({
2087
+ * universalIdentifier: "6f1c…",
2088
+ * nameSingular: "permit", namePlural: "permits",
2089
+ * labelSingular: "Permit", labelPlural: "Permits",
2090
+ * labelIdentifierFieldName: "title",
2091
+ * fields: [
2092
+ * defineCollection({ universalIdentifier: "a2b4…", name: "title", label: "Title", type: "TEXT" }),
2093
+ * ],
2094
+ * }),
2095
+ * ],
2096
+ * })
2097
+ * ```
2098
+ */
2099
+ export function createSchema(
2100
+ input: DataModelInput,
2101
+ options: SchemaOptions = {},
2102
+ ): DataModelClientShape {
2103
+ const objects = input.collections ?? input.objects ?? [];
2104
+ const metadata = input.metadata ?? {};
2105
+
2106
+ if (objects.length === 0) {
2107
+ throw new Error("createSchema: declare at least one collection.");
2108
+ }
2109
+
2110
+ const globalIdType: PrimaryKeyType = options.defaultIdType ?? "uuid";
2111
+
2112
+ // Auto-inject primary key id field for any mutable collection that doesn't define one.
2113
+ // Frozen objects (from the old defineField/defineCollection API) are skipped — they
2114
+ // explicitly declare all their fields.
2115
+ for (const object of objects) {
2116
+ if (Object.isFrozen(object)) continue;
2117
+ const col = object as CollectionDefinition;
2118
+ const hasId =
2119
+ col.fields.some((f) => f.name === "id") ||
2120
+ col.fields.some((f) => f.isPrimaryKey);
2121
+ if (hasId) continue;
2122
+ const idStrategy: PrimaryKeyType = col.idType ?? globalIdType;
2123
+ const idUid = generateUniversalIdentifier(
2124
+ `biab:collection:${object.nameSingular}:field:id`,
2125
+ );
2126
+ col.fields.unshift({
2127
+ universalIdentifier: idUid,
2128
+ name: "id",
2129
+ label: "ID",
2130
+ type: idStrategy === "integer" ? "NUMBER" : "UUID",
2131
+ isPrimaryKey: true,
2132
+ isRequired: true,
2133
+ isNullable: false,
2134
+ isAutoIncrement: idStrategy === "integer",
2135
+ position: 0,
2136
+ });
2137
+ // Shift existing field positions
2138
+ for (let i = 1; i < col.fields.length; i++) {
2139
+ const f = col.fields[i];
2140
+ if (f) f.position = i;
2141
+ }
2142
+ }
2143
+
2144
+ // Cross-object checks. defineObject can only see itself, so uniqueness
2145
+ // across the model and relation targets have to be settled here.
2146
+ const byIdentifier = new Map<string, ObjectDefinition>();
2147
+ const byName = new Map<string, ObjectDefinition>();
2148
+ const names = new Set<string>();
2149
+ for (const object of objects) {
2150
+ if (byIdentifier.has(object.universalIdentifier)) {
2151
+ throw new Error(
2152
+ `defineDataModel: two objects share universalIdentifier "${object.universalIdentifier}" ("${object.nameSingular}" and "${byIdentifier.get(object.universalIdentifier)?.nameSingular}").`,
2153
+ );
2154
+ }
2155
+ byIdentifier.set(object.universalIdentifier, object);
2156
+ byName.set(object.nameSingular, object);
2157
+ byName.set(object.namePlural, object);
2158
+
2159
+ const seen = new Set<string>();
2160
+ for (const name of [object.nameSingular, object.namePlural]) {
2161
+ if (seen.has(name)) continue;
2162
+ seen.add(name);
2163
+ if (names.has(name)) {
2164
+ throw new Error(
2165
+ `defineDataModel: object name "${name}" is used twice — names must be unique across the model.`,
2166
+ );
2167
+ }
2168
+ names.add(name);
2169
+ }
2170
+ }
2171
+
2172
+ // Auto-generate junction collections for many-to-many relations
2173
+ const generatedJunctions: ObjectDefinition[] = [];
2174
+ for (const object of objects) {
2175
+ const objectWithRelations = object as CollectionDefinition;
2176
+ if (!objectWithRelations.relations) continue;
2177
+ for (const rel of objectWithRelations.relations) {
2178
+ if (rel.meta.type !== "many-to-many") continue;
2179
+ const targetName = rel.meta.targetCollection;
2180
+ const targetObj = byName.get(targetName);
2181
+ if (!targetObj) {
2182
+ throw new Error(
2183
+ `defineDataModel: "${object.nameSingular}.${rel.fieldName}" belongsToMany "${targetName}", which no object in this model declares.`,
2184
+ );
2185
+ }
2186
+ const joinName =
2187
+ rel.meta.joinCollectionName ??
2188
+ `${object.nameSingular}_${targetObj.nameSingular}`;
2189
+ const {
2190
+ nameSingular: joinSingular,
2191
+ namePlural: joinPlural,
2192
+ labelSingular: joinLabelSingular,
2193
+ labelPlural: joinLabelPlural,
2194
+ } = parseTableName(joinName);
2195
+
2196
+ // Build FK fields from junction to source (left side) and target (right side)
2197
+ const selfRelation =
2198
+ object.universalIdentifier === targetObj.universalIdentifier;
2199
+ const capitalizedSource =
2200
+ object.nameSingular.charAt(0).toUpperCase() +
2201
+ object.nameSingular.slice(1);
2202
+ const sourceFkName = selfRelation
2203
+ ? `source${capitalizedSource}Id`
2204
+ : `${object.nameSingular}Id`;
2205
+ const targetFkName = selfRelation
2206
+ ? `target${capitalizedSource}Id`
2207
+ : `${targetObj.nameSingular}Id`;
2208
+ const junctionUid = generateUniversalIdentifier(
2209
+ `biab:collection:${joinSingular}`,
2210
+ );
2211
+ const sourceUid = generateUniversalIdentifier(
2212
+ `biab:collection:${joinSingular}:field:${sourceFkName}`,
2213
+ );
2214
+ const targetUid = generateUniversalIdentifier(
2215
+ `biab:collection:${joinSingular}:field:${targetFkName}`,
2216
+ );
2217
+
2218
+ // Resolve cascade behavior: explicit onDeleteAction, then default to CASCADE
2219
+ const junctionOnDelete: RelationOnDelete =
2220
+ rel.meta.onDeleteAction !== undefined
2221
+ ? (DELETE_ACTION_TO_ON_DELETE[rel.meta.onDeleteAction] ?? "cascade")
2222
+ : "cascade";
2223
+
2224
+ // Determine id strategy for junction (use source collection's idType or global)
2225
+ const junctionIdType: PrimaryKeyType =
2226
+ (object as CollectionDefinition).idType ?? globalIdType;
2227
+ const junctionIdUid = generateUniversalIdentifier(
2228
+ `biab:collection:${joinSingular}:field:id`,
2229
+ );
2230
+
2231
+ const junctionFields: FieldDefinition[] = [
2232
+ {
2233
+ universalIdentifier: junctionIdUid,
2234
+ name: "id",
2235
+ label: "ID",
2236
+ type: junctionIdType === "integer" ? "NUMBER" : "UUID",
2237
+ isPrimaryKey: true,
2238
+ isRequired: true,
2239
+ isNullable: false,
2240
+ isAutoIncrement: junctionIdType === "integer",
2241
+ position: 0,
2242
+ },
2243
+ {
2244
+ universalIdentifier: sourceUid,
2245
+ name: sourceFkName,
2246
+ label: toLabel(sourceFkName),
2247
+ type: "RELATION",
2248
+ relationTarget: object.universalIdentifier,
2249
+ onDelete: junctionOnDelete,
2250
+ isNullable: false,
2251
+ recommendedFormElements: ["collection_select"],
2252
+ position: 1,
2253
+ },
2254
+ {
2255
+ universalIdentifier: targetUid,
2256
+ name: targetFkName,
2257
+ label: toLabel(targetFkName),
2258
+ type: "RELATION",
2259
+ relationTarget: targetObj.universalIdentifier,
2260
+ onDelete: junctionOnDelete,
2261
+ isNullable: false,
2262
+ recommendedFormElements: ["collection_select"],
2263
+ position: 2,
2264
+ },
2265
+ ];
2266
+
2267
+ // Add any additional fields
2268
+ if (rel.meta.fields) {
2269
+ let pos = 3;
2270
+ for (const [addName, addBuilder] of Object.entries(rel.meta.fields)) {
2271
+ const addDef = addBuilder.build();
2272
+ junctionFields.push({
2273
+ ...addDef,
2274
+ universalIdentifier: generateUniversalIdentifier(
2275
+ `biab:collection:${joinSingular}:field:${addName}`,
2276
+ ),
2277
+ name: addName,
2278
+ label: toLabel(addName),
2279
+ position: pos,
2280
+ });
2281
+ pos++;
2282
+ }
2283
+ }
2284
+
2285
+ const junctionObj: ObjectDefinition = {
2286
+ universalIdentifier: junctionUid,
2287
+ kind: "custom",
2288
+ nameSingular: joinSingular,
2289
+ namePlural: joinPlural,
2290
+ labelSingular: joinLabelSingular,
2291
+ labelPlural: joinLabelPlural,
2292
+ labelIdentifierFieldName: sourceFkName,
2293
+ fields: junctionFields,
2294
+ };
2295
+
2296
+ generatedJunctions.push(junctionObj);
2297
+ byIdentifier.set(junctionUid, junctionObj);
2298
+ byName.set(joinSingular, junctionObj);
2299
+ byName.set(joinPlural, junctionObj);
2300
+ names.add(joinSingular);
2301
+ names.add(joinPlural);
2302
+ }
2303
+ }
2304
+
2305
+ // Append generated junction collections to the objects list
2306
+ objects.push(...generatedJunctions);
2307
+
2308
+ // Resolve relation targets by collection name (allowing name-based references)
2309
+ for (const object of objects) {
2310
+ for (const field of object.fields) {
2311
+ if (field.type !== "RELATION" || !field.relationTarget) continue;
2312
+ if (
2313
+ !byIdentifier.has(field.relationTarget) &&
2314
+ byName.has(field.relationTarget)
2315
+ ) {
2316
+ field.relationTarget = byName.get(
2317
+ field.relationTarget,
2318
+ )!.universalIdentifier;
2319
+ }
2320
+ }
2321
+ }
2322
+
2323
+ // A relation pointing at an object that isn't in the model can never be
2324
+ // resolved. Catch it here rather than as a server-side NOT_FOUND.
2325
+ for (const object of objects) {
2326
+ for (const field of object.fields) {
2327
+ if (field.type !== "RELATION" || !field.relationTarget) continue;
2328
+ if (!byIdentifier.has(field.relationTarget)) {
2329
+ throw new Error(
2330
+ `defineDataModel: "${object.nameSingular}.${field.name}" points at "${field.relationTarget}", which no object in this model declares.`,
2331
+ );
2332
+ }
2333
+ }
2334
+ }
2335
+
2336
+ // Validate form element compatibility
2337
+ for (const object of objects) {
2338
+ if (Object.isFrozen(object)) continue;
2339
+ for (const field of object.fields) {
2340
+ if (!field.formElement) continue;
2341
+ const isValid = isValidFormElement(field.type, field.formElement);
2342
+ if (!isValid) {
2343
+ const recommended = getRecommendedFormElements(field.type);
2344
+ console.warn(
2345
+ `[BusinessDash SDK] Form element mismatch in "${object.nameSingular}.${field.name}": ` +
2346
+ `"${field.formElement}" does not fulfill field type "${field.type}". ` +
2347
+ (recommended.length > 0
2348
+ ? `Recommended: [${recommended.join(", ")}]`
2349
+ : "No compatible elements for this field type."),
2350
+ );
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ // Compile-time validation rule checks
2356
+ for (const object of objects) {
2357
+ if (Object.isFrozen(object)) continue;
2358
+ for (const field of object.fields) {
2359
+ const rules = field.validation;
2360
+ if (!rules) continue;
2361
+ const name = `"${object.nameSingular}.${field.name}"`;
2362
+
2363
+ // Numerical type gating
2364
+ const isNumeric = field.type === "NUMBER" || field.type === "NUMERIC";
2365
+ const isTextOrArray = field.type === "TEXT" || field.type === "ARRAY";
2366
+
2367
+ // minLength / maxLength only on TEXT or ARRAY
2368
+ if (
2369
+ (rules.minLength !== undefined || rules.maxLength !== undefined) &&
2370
+ !isTextOrArray
2371
+ ) {
2372
+ throw new Error(
2373
+ `[BusinessDash SDK] Validation error on ${name}: "minLength" and "maxLength" are only applicable to TEXT or ARRAY, not "${field.type}".`,
2374
+ );
2375
+ }
2376
+ if (
2377
+ (rules.minLength !== undefined && rules.minLength < 0) ||
2378
+ (rules.maxLength !== undefined && rules.maxLength < 0)
2379
+ ) {
2380
+ throw new Error(
2381
+ `[BusinessDash SDK] Validation error on ${name}: "minLength" and "maxLength" must be positive integers.`,
2382
+ );
2383
+ }
2384
+ if (
2385
+ rules.minLength !== undefined &&
2386
+ rules.maxLength !== undefined &&
2387
+ rules.minLength > rules.maxLength
2388
+ ) {
2389
+ throw new Error(
2390
+ `[BusinessDash SDK] Validation error on ${name}: "minLength" (${rules.minLength}) cannot exceed "maxLength" (${rules.maxLength}).`,
2391
+ );
2392
+ }
2393
+
2394
+ // Numeric boundary checks
2395
+ if (
2396
+ rules.lessThan !== undefined &&
2397
+ rules.greaterThan !== undefined &&
2398
+ rules.greaterThan >= rules.lessThan
2399
+ ) {
2400
+ throw new Error(
2401
+ `[BusinessDash SDK] Validation error on ${name}: "greaterThan" (${rules.greaterThan}) must be strictly less than "lessThan" (${rules.lessThan}).`,
2402
+ );
2403
+ }
2404
+
2405
+ // Math rules gated to numeric fields
2406
+ if (
2407
+ (rules.floor ||
2408
+ rules.isDecimal ||
2409
+ rules.round !== undefined ||
2410
+ rules.positive) &&
2411
+ !isNumeric
2412
+ ) {
2413
+ throw new Error(
2414
+ `[BusinessDash SDK] Validation error on ${name}: "floor", "isDecimal", "round", and "positive" are only applicable to NUMBER/NUMERIC, not "${field.type}".`,
2415
+ );
2416
+ }
2417
+
2418
+ // round must be non-negative integer
2419
+ if (
2420
+ rules.round !== undefined &&
2421
+ (rules.round < 0 || !Number.isInteger(rules.round))
2422
+ ) {
2423
+ throw new Error(
2424
+ `[BusinessDash SDK] Validation error on ${name}: "round" must be a non-negative whole integer (0, 1, 2, ...).`,
2425
+ );
2426
+ }
2427
+
2428
+ // floor and round conflict
2429
+ if (rules.floor && rules.round !== undefined) {
2430
+ throw new Error(
2431
+ `[BusinessDash SDK] Validation error on ${name}: "floor" and "round" cannot be applied together.`,
2432
+ );
2433
+ }
2434
+
2435
+ // floor/round with isDecimal conflict
2436
+ if ((rules.floor || rules.round !== undefined) && rules.isDecimal) {
2437
+ throw new Error(
2438
+ `[BusinessDash SDK] Validation error on ${name}: "floor" or "round" returns whole integers, neutralizing "isDecimal".`,
2439
+ );
2440
+ }
2441
+
2442
+ // Date conflict checks
2443
+ if (rules.lessThanToday && rules.greaterThanToday) {
2444
+ throw new Error(
2445
+ `[BusinessDash SDK] Validation error on ${name}: cannot be both "lessThanToday" and "greaterThanToday".`,
2446
+ );
2447
+ }
2448
+ }
2449
+ }
2450
+
2451
+ let cached: DataModelArtifact | null = null;
2452
+
2453
+ function build(): DataModelArtifact {
2454
+ if (cached) return cached;
2455
+ const out: DataModelArtifact = {
2456
+ $schema: "https://biab.app/schemas/data-model/v1",
2457
+ version: 1,
2458
+ metadata: { ...metadata },
2459
+ objects: objects.map((object) => ({
2460
+ universalIdentifier: object.universalIdentifier,
2461
+ kind: object.kind ?? "custom",
2462
+ nameSingular: object.nameSingular,
2463
+ namePlural: object.namePlural,
2464
+ labelSingular: object.labelSingular,
2465
+ labelPlural: object.labelPlural,
2466
+ // ⚠️ Omitted when private — including when someone wrote
2467
+ // `visibility: 'private'` by hand. Absent and private are the same
2468
+ // model, so they must produce the same bytes.
2469
+ //
2470
+ // This is a checksum constraint, not a style choice. The CLI hashes
2471
+ // the artifact and the server recomputes it against a pinned golden
2472
+ // digest. Materializing this key would put `visibility` into EVERY
2473
+ // object of EVERY existing config and change every checksum that has
2474
+ // ever been computed — for a field nobody set.
2475
+ ...(object.visibility !== undefined && object.visibility !== "private"
2476
+ ? { visibility: object.visibility }
2477
+ : {}),
2478
+ ...(object.icon !== undefined ? { icon: object.icon } : {}),
2479
+ ...(object.description !== undefined
2480
+ ? { description: object.description }
2481
+ : {}),
2482
+ ...(object.labelIdentifierFieldName !== undefined
2483
+ ? { labelIdentifierFieldName: object.labelIdentifierFieldName }
2484
+ : {}),
2485
+ ...(object.standardEntityName !== undefined
2486
+ ? { standardEntityName: object.standardEntityName }
2487
+ : {}),
2488
+ fields: object.fields.map((field) => ({
2489
+ universalIdentifier: field.universalIdentifier,
2490
+ name: field.name,
2491
+ label: field.label,
2492
+ type: field.type,
2493
+ ...(field.icon !== undefined ? { icon: field.icon } : {}),
2494
+ ...(field.description !== undefined
2495
+ ? { description: field.description }
2496
+ : {}),
2497
+ ...(field.isNullable !== undefined
2498
+ ? { isNullable: field.isNullable }
2499
+ : {}),
2500
+ ...(field.isUnique !== undefined ? { isUnique: field.isUnique } : {}),
2501
+ ...(field.isIndexed !== undefined
2502
+ ? { isIndexed: field.isIndexed }
2503
+ : {}),
2504
+ ...(field.isPrimaryKey !== undefined
2505
+ ? { isPrimaryKey: field.isPrimaryKey }
2506
+ : {}),
2507
+ ...(field.isAutoIncrement !== undefined
2508
+ ? { isAutoIncrement: field.isAutoIncrement }
2509
+ : {}),
2510
+ ...(field.defaultValue !== undefined
2511
+ ? { defaultValue: field.defaultValue }
2512
+ : {}),
2513
+ ...(field.position !== undefined ? { position: field.position } : {}),
2514
+ ...(field.options !== undefined ? { options: field.options } : {}),
2515
+ ...(field.relationTarget !== undefined
2516
+ ? { relationTarget: field.relationTarget }
2517
+ : {}),
2518
+ ...(field.onDelete !== undefined ? { onDelete: field.onDelete } : {}),
2519
+ ...(field.strategy !== undefined ? { strategy: field.strategy } : {}),
2520
+ ...(field.separateTableName !== undefined
2521
+ ? { separateTableName: field.separateTableName }
2522
+ : {}),
2523
+ ...(field.formElement !== undefined
2524
+ ? { formElement: field.formElement }
2525
+ : {}),
2526
+ ...(field.elementMetadata !== undefined
2527
+ ? { elementMetadata: field.elementMetadata }
2528
+ : {}),
2529
+ ...(field.recommendedFormElements !== undefined
2530
+ ? { recommendedFormElements: field.recommendedFormElements }
2531
+ : {}),
2532
+ ...(field.validation !== undefined
2533
+ ? { validation: field.validation }
2534
+ : {}),
2535
+ ...(field.helperText !== undefined
2536
+ ? { helperText: field.helperText }
2537
+ : {}),
2538
+ ...(field.dynamicOptions !== undefined
2539
+ ? { dynamicOptions: field.dynamicOptions }
2540
+ : {}),
2541
+ })),
2542
+ })),
2543
+ };
2544
+ cached = out;
2545
+ return out;
2546
+ }
2547
+
2548
+ return {
2549
+ objects: objects.map((o) => Object.freeze(o)),
2550
+ toJSONSchema: build,
2551
+ async checksum() {
2552
+ return computeDataModelChecksum(
2553
+ build() as unknown as Record<string, unknown>,
2554
+ );
2555
+ },
2556
+ };
2557
+ }
2558
+
2559
+ /**
2560
+ * @deprecated Use `createSchema` instead. `defineDataModel` will be removed in a future release.
2561
+ */
2562
+ export function defineDataModel(input: DataModelInput): DataModelClientShape {
2563
+ console.warn(
2564
+ "[@businessdash/sdk] defineDataModel is deprecated. Please use createSchema instead.",
2565
+ );
2566
+ return createSchema(input);
2567
+ }
2568
+
2569
+ // ── Form + Schema Orchestrator ────────────────────────────────────────────
2570
+ // Layout wrappers that live entirely outside the database layer — collections
2571
+ // stay pure. The parser validates every layout reference against the compiled
2572
+ // schema and throws on any mismatch.
2573
+
2574
+ export type ProgressStyle = "multi-step" | "progress_bar";
2575
+
2576
+ export type LogicalOperator = "AND" | "OR";
2577
+
2578
+ export type RuleMatchOperator = "equals" | "not_equals" | "any_of" | "none_of";
2579
+
2580
+ export interface EvaluationRule {
2581
+ fieldRef: string;
2582
+ op: RuleMatchOperator;
2583
+ values: unknown[];
2584
+ }
2585
+
2586
+ // ── Type utilities for compile-time field path inference ──────────────
2587
+
2588
+ /** Map a FieldType to its corresponding JavaScript runtime type. */
2589
+ export type InferJsType<T extends FieldType> = T extends
2590
+ | "NUMBER"
2591
+ | "NUMERIC"
2592
+ | "CURRENCY"
2593
+ | "RATING"
2594
+ | "POSITION"
2595
+ ? number
2596
+ : T extends "BOOLEAN"
2597
+ ? boolean
2598
+ : T extends "ARRAY" | "MULTI_SELECT" | "FILES"
2599
+ ? unknown[]
2600
+ : T extends "RAW_JSON" | "ADDRESS"
2601
+ ? unknown
2602
+ : string;
2603
+
2604
+ /** Infer the submitted/runtime object for one collection declaration. */
2605
+ export type InferCollectionValues<C> =
2606
+ C extends CollectionDefinition<infer F> ? InferFieldsValues<F> : never;
2607
+
2608
+ /**
2609
+ * Infer values for a field map, one collection, or a map of collections used
2610
+ * by a custom form.
2611
+ */
2612
+ export type InferFormValues<T> = T extends CollectionDefinition
2613
+ ? InferCollectionValues<T>
2614
+ : T extends Record<string, AnyFieldBuilder>
2615
+ ? InferFieldsValues<T>
2616
+ : T extends Record<string, CollectionDefinition>
2617
+ ? { [K in keyof T]: InferCollectionValues<T[K]> }
2618
+ : never;
2619
+
2620
+ /** Infer a keyed value map for an entire schema's collection declarations. */
2621
+ export type InferSchemaValues<T extends Record<string, CollectionDefinition>> =
2622
+ {
2623
+ [K in keyof T]: InferCollectionValues<T[K]>;
2624
+ };
2625
+
2626
+ /** Extract all "collectionName.fieldName" paths from a tuple of CollectionDefinitions. */
2627
+ export type ExtractFieldPaths<C extends readonly CollectionDefinition[]> = {
2628
+ [I in keyof C]: C[I] extends CollectionDefinition<infer F>
2629
+ ? {
2630
+ [K in keyof F & string]: C[I]["nameSingular"] extends string
2631
+ ? `${C[I]["nameSingular"]}.${K}`
2632
+ : never;
2633
+ }[keyof F & string]
2634
+ : never;
2635
+ }[number];
2636
+
2637
+ /** Resolve a dot path like "projects.budget" to its JS type. */
2638
+ export type FindFieldTypeByPath<
2639
+ Path extends string,
2640
+ C extends readonly CollectionDefinition[],
2641
+ > =
2642
+ C[number] extends CollectionDefinition<infer F>
2643
+ ? Path extends `${infer N}.${infer K}`
2644
+ ? K extends keyof F
2645
+ ? InferFieldValue<F[K]>
2646
+ : never
2647
+ : never
2648
+ : never;
2649
+
2650
+ /** A conditional rule with compile-time validation against the schema. */
2651
+ export type SecureEvalRule<
2652
+ C extends readonly CollectionDefinition[],
2653
+ Path extends ExtractFieldPaths<C> = ExtractFieldPaths<C>,
2654
+ > = {
2655
+ fieldReference: Path;
2656
+ op: "equals" | "not_equals" | "any_of" | "none_of";
2657
+ values: FindFieldTypeByPath<Path, C>[];
2658
+ };
2659
+
2660
+ // ── Layout node types ─────────────────────────────────────────────────
2661
+
2662
+ export interface OptionGroup<
2663
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2664
+ > {
2665
+ name: string;
2666
+ nodes: FormNode<C>[];
2667
+ helperText?: string;
2668
+ }
2669
+
2670
+ export type FormNode<
2671
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2672
+ > =
2673
+ | {
2674
+ type: "render_collection";
2675
+ collectionId: ExtractFieldPaths<C> extends never
2676
+ ? string
2677
+ : C[number]["nameSingular"];
2678
+ helperText?: string;
2679
+ }
2680
+ | {
2681
+ type: "step_group";
2682
+ name: string;
2683
+ helperText?: string;
2684
+ children: FormNode<C>[];
2685
+ }
2686
+ | {
2687
+ type: "conditional_group";
2688
+ fieldReference: ExtractFieldPaths<C> extends never
2689
+ ? string
2690
+ : ExtractFieldPaths<C>;
2691
+ condition: "any_of" | "all_of" | "equals";
2692
+ values: unknown[];
2693
+ rules?: SecureEvalRule<C>[];
2694
+ helperText?: string;
2695
+ children: FormNode<C>[];
2696
+ }
2697
+ | {
2698
+ type: "or_group";
2699
+ helperText?: string;
2700
+ button_label?: string;
2701
+ options: OptionGroup<C>[];
2702
+ }
2703
+ | {
2704
+ type: "multi_populator";
2705
+ collectionId: ExtractFieldPaths<C> extends never
2706
+ ? string
2707
+ : C[number]["nameSingular"];
2708
+ max_entries?: number;
2709
+ button_label?: string;
2710
+ helperText?: string;
2711
+ };
2712
+
2713
+ // ── 0.9.50 layout container node types ────────────────────────────────
2714
+
2715
+ export interface FormStepNode {
2716
+ type: "form_step";
2717
+ name: string;
2718
+ showTitle?: boolean;
2719
+ helperText?: string;
2720
+ children: LayoutNode[];
2721
+ }
2722
+
2723
+ export interface ConditionalBlockNode {
2724
+ type: "conditional_block";
2725
+ matchStrategy: LogicalOperator;
2726
+ rules: EvaluationRule[];
2727
+ showTitle?: boolean;
2728
+ helperText?: string;
2729
+ children: LayoutNode[];
2730
+ }
2731
+
2732
+ export interface MultiPopulatorNode {
2733
+ type: "multi_populator";
2734
+ max_entries?: number;
2735
+ button_label?: string;
2736
+ showTitle?: boolean;
2737
+ helperText?: string;
2738
+ /**
2739
+ * Where each repeated entry is persisted. When omitted, the orchestrator
2740
+ * infers `separateTable` from the repeater's single child collection.
2741
+ */
2742
+ output?: MultiPopulatorOutput;
2743
+ /**
2744
+ * Field template for JSONB entries. Secondary-table repeaters derive their
2745
+ * fields from the target collection instead.
2746
+ */
2747
+ entryFields?: FieldDefinition[];
2748
+ children: LayoutNode[];
2749
+ }
2750
+
2751
+ export type MultiPopulatorOutput =
2752
+ | {
2753
+ strategy: "jsonb";
2754
+ /** Existing `collection.field` whose field declares `.strategy("jsonb")`. */
2755
+ fieldRef: string;
2756
+ }
2757
+ | {
2758
+ strategy: "separateTable";
2759
+ /** Target collection; inferred when the repeater has exactly one child collection. */
2760
+ collectionId?: string;
2761
+ };
2762
+
2763
+ export interface MultiPopulatorConfig {
2764
+ max_entries?: number;
2765
+ button_label?: string;
2766
+ showTitle?: boolean;
2767
+ helperText?: string;
2768
+ output?: MultiPopulatorOutput;
2769
+ /**
2770
+ * Schema for one JSONB array entry. Required for `output.strategy: "jsonb"`
2771
+ * and invalid for secondary-table output.
2772
+ */
2773
+ entryFields?: Record<string, AnyFieldBuilder>;
2774
+ }
2775
+
2776
+ export interface OrConditionNode {
2777
+ type: "or_condition";
2778
+ showTitle?: boolean;
2779
+ helperText?: string;
2780
+ button_label?: string;
2781
+ branches: OptionGroup[];
2782
+ }
2783
+
2784
+ export interface CollectionRefNode {
2785
+ type: "render_collection";
2786
+ collectionId: string;
2787
+ helperText?: string;
2788
+ }
2789
+
2790
+ export type LayoutNode =
2791
+ | FormStepNode
2792
+ | ConditionalBlockNode
2793
+ | MultiPopulatorNode
2794
+ | OrConditionNode
2795
+ | CollectionRefNode;
2796
+
2797
+ export interface FormOrchestrationOptions {
2798
+ defaultIdType?: PrimaryKeyType;
2799
+ consecutive?: boolean;
2800
+ progress?: boolean;
2801
+ progress_style?: ProgressStyle;
2802
+ }
2803
+
2804
+ export interface FormAndSchemaConfig<
2805
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2806
+ > {
2807
+ collections: C;
2808
+ layout: FormNode<C>[];
2809
+ options?: FormOrchestrationOptions;
2810
+ }
2811
+
2812
+ /** A custom-form draft generated from one compiled collection. */
2813
+ export type GeneratedFormCandidate = {
2814
+ /** Stable key used by BIAB to update this generated draft idempotently. */
2815
+ sourceKey: string;
2816
+ name: string;
2817
+ slug: string;
2818
+ description: string;
2819
+ fields: Array<{
2820
+ id: string;
2821
+ label: string;
2822
+ /** BIAB custom-form renderer element type. */
2823
+ type: string;
2824
+ required: boolean;
2825
+ outputKey: string;
2826
+ options?: string[];
2827
+ defaultValue?: string | boolean;
2828
+ placeholder?: string;
2829
+ helpText?: string;
2830
+ regexPattern?: string;
2831
+ validation?: { min?: number; max?: number };
2832
+ collectionSelectConfig?: {
2833
+ collectionKey: string;
2834
+ labelField?: string;
2835
+ valueMode: "id";
2836
+ multi?: boolean;
2837
+ };
2838
+ }>;
2839
+ settings: {
2840
+ isConsecutive: boolean;
2841
+ showProgress: boolean;
2842
+ progressStyle?: "bar" | "steps";
2843
+ generatedFrom: {
2844
+ kind: "data_model";
2845
+ sourceKey: string;
2846
+ objectIdentifier?: string;
2847
+ objectIdentifiers?: string[];
2848
+ };
2849
+ actions: Array<{
2850
+ id: string;
2851
+ type: "create_records";
2852
+ enabled: true;
2853
+ mapping: Record<string, string>;
2854
+ config: {
2855
+ composite: {
2856
+ creates: Array<{
2857
+ alias: string;
2858
+ object: string;
2859
+ fieldMap: Record<string, string>;
2860
+ links?: Array<{ field: string; to: string }>;
2861
+ }>;
2862
+ };
2863
+ };
2864
+ }>;
2865
+ };
2866
+ };
2867
+
2868
+ // ── Schema Boundary ──────────────────────────────────────────────────
2869
+
2870
+ export type BoundaryMetadata = {
2871
+ featureId: string;
2872
+ name: string;
2873
+ description?: string;
2874
+ };
2875
+
2876
+ export type SchemaBoundaryNode = {
2877
+ type: "schema_boundary";
2878
+ meta: BoundaryMetadata;
2879
+ children: (CollectionDefinition | SchemaBoundaryNode | LayoutNode)[];
2880
+ };
2881
+
2882
+ /**
2883
+ * Group collections (and nested boundaries) under a named feature.
2884
+ * The orchestrator flattens these when building the schema graph.
2885
+ *
2886
+ * ```ts
2887
+ * SchemaBoundary(
2888
+ * { featureId: "crm", name: "CRM", description: "Contact management" },
2889
+ * collection("contacts", { fields: { name: fields.text() } }),
2890
+ * )
2891
+ * ```
2892
+ */
2893
+ export function SchemaBoundary(
2894
+ meta: BoundaryMetadata,
2895
+ ...children: (CollectionDefinition | SchemaBoundaryNode | LayoutNode)[]
2896
+ ): SchemaBoundaryNode {
2897
+ return { type: "schema_boundary", meta, children };
2898
+ }
2899
+
2900
+ /**
2901
+ * @deprecated Use `layoutContainers` instead. This API will be removed in a future release.
2902
+ */
2903
+ export const layout = {
2904
+ step: <
2905
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2906
+ >(
2907
+ name: string,
2908
+ children: FormNode<C>[],
2909
+ options?: { helperText?: string },
2910
+ ): FormNode<C> =>
2911
+ ({
2912
+ type: "step_group",
2913
+ name,
2914
+ ...(options?.helperText !== undefined
2915
+ ? { helperText: options.helperText }
2916
+ : {}),
2917
+ children,
2918
+ }) as FormNode<C>,
2919
+ collection: <
2920
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2921
+ >(
2922
+ collectionId: string,
2923
+ options?: { helperText?: string },
2924
+ ): FormNode<C> =>
2925
+ ({
2926
+ type: "render_collection",
2927
+ collectionId,
2928
+ ...(options?.helperText !== undefined
2929
+ ? { helperText: options.helperText }
2930
+ : {}),
2931
+ }) as FormNode<C>,
2932
+ conditional: <
2933
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2934
+ >(
2935
+ fieldReference: string,
2936
+ condition: "any_of" | "all_of" | "equals",
2937
+ values: unknown[],
2938
+ rules: SecureEvalRule<C>[] | undefined,
2939
+ children: FormNode<C>[],
2940
+ options?: { helperText?: string },
2941
+ ): FormNode<C> =>
2942
+ ({
2943
+ type: "conditional_group",
2944
+ fieldReference,
2945
+ condition,
2946
+ values,
2947
+ rules,
2948
+ ...(options?.helperText !== undefined
2949
+ ? { helperText: options.helperText }
2950
+ : {}),
2951
+ children,
2952
+ }) as FormNode<C>,
2953
+ or: <
2954
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2955
+ >(
2956
+ options: OptionGroup<C>[],
2957
+ containerOptions?: { helperText?: string; button_label?: string },
2958
+ ): FormNode<C> =>
2959
+ ({
2960
+ type: "or_group",
2961
+ options,
2962
+ ...(containerOptions?.helperText !== undefined
2963
+ ? { helperText: containerOptions.helperText }
2964
+ : {}),
2965
+ ...(containerOptions?.button_label !== undefined
2966
+ ? { button_label: containerOptions.button_label }
2967
+ : {}),
2968
+ }) as FormNode<C>,
2969
+ multiPopulator: <
2970
+ C extends readonly CollectionDefinition[] = readonly CollectionDefinition[],
2971
+ >(
2972
+ collectionId: string,
2973
+ options?: {
2974
+ max_entries?: number;
2975
+ button_label?: string;
2976
+ helperText?: string;
2977
+ },
2978
+ ): FormNode<C> =>
2979
+ ({
2980
+ type: "multi_populator",
2981
+ collectionId,
2982
+ ...(options?.max_entries !== undefined
2983
+ ? { max_entries: options.max_entries }
2984
+ : {}),
2985
+ ...(options?.button_label !== undefined
2986
+ ? { button_label: options.button_label }
2987
+ : {}),
2988
+ ...(options?.helperText !== undefined
2989
+ ? { helperText: options.helperText }
2990
+ : {}),
2991
+ }) as FormNode<C>,
2992
+ };
2993
+
2994
+ // ── layoutContainers (0.9.50+) ─────────────────────────────────────────────
2995
+
2996
+ /**
2997
+ * Semantic layout wrappers for form structure. Each container isolates
2998
+ * UI flow rules without contaminating your core database collections.
2999
+ *
3000
+ * ```ts
3001
+ * import { layoutContainers } from "@businessdash/sdk";
3002
+ * const { FormStep, ConditionalBlock, MultiPopulator, OrCondition } = layoutContainers;
3003
+ * ```
3004
+ */
3005
+ type LayoutChild = LayoutNode | CollectionDefinition;
3006
+
3007
+ function createFormStepNode(
3008
+ name: string,
3009
+ options: { showTitle?: boolean; helperText?: string } = {},
3010
+ ...children: LayoutChild[]
3011
+ ): FormStepNode {
3012
+ return {
3013
+ type: "form_step",
3014
+ name,
3015
+ ...(options.showTitle !== undefined
3016
+ ? { showTitle: options.showTitle }
3017
+ : {}),
3018
+ ...(options.helperText !== undefined
3019
+ ? { helperText: options.helperText }
3020
+ : {}),
3021
+ children: children as LayoutNode[],
3022
+ } as FormStepNode;
3023
+ }
3024
+
3025
+ function createConditionalBlockNode(
3026
+ config: {
3027
+ matchStrategy: LogicalOperator;
3028
+ rules: EvaluationRule[];
3029
+ showTitle?: boolean;
3030
+ helperText?: string;
3031
+ },
3032
+ ...children: LayoutChild[]
3033
+ ): ConditionalBlockNode {
3034
+ return {
3035
+ type: "conditional_block",
3036
+ matchStrategy: config.matchStrategy,
3037
+ rules: config.rules,
3038
+ ...(config.showTitle !== undefined ? { showTitle: config.showTitle } : {}),
3039
+ ...(config.helperText !== undefined
3040
+ ? { helperText: config.helperText }
3041
+ : {}),
3042
+ children: children as LayoutNode[],
3043
+ } as ConditionalBlockNode;
3044
+ }
3045
+
3046
+ function createMultiPopulatorNode(
3047
+ config: MultiPopulatorConfig = {},
3048
+ ...children: LayoutChild[]
3049
+ ): MultiPopulatorNode {
3050
+ const entryFields = config.entryFields
3051
+ ? Object.entries(config.entryFields).map(([name, builder], position) => {
3052
+ const relationMeta = getRelationMeta(builder);
3053
+ if (relationMeta?.type === "many-to-many") {
3054
+ throw new Error(
3055
+ `[BusinessDash SDK] MultiPopulator JSONB entry field "${name}" cannot use belongsToMany(). Use a secondary-table output for relational entries.`,
3056
+ );
3057
+ }
3058
+ return {
3059
+ ...builder.build(),
3060
+ name,
3061
+ label: toLabel(name),
3062
+ position,
3063
+ };
3064
+ })
3065
+ : undefined;
3066
+
3067
+ return {
3068
+ type: "multi_populator",
3069
+ ...(config.max_entries !== undefined
3070
+ ? { max_entries: config.max_entries }
3071
+ : {}),
3072
+ ...(config.button_label !== undefined
3073
+ ? { button_label: config.button_label }
3074
+ : {}),
3075
+ ...(config.showTitle !== undefined ? { showTitle: config.showTitle } : {}),
3076
+ ...(config.helperText !== undefined
3077
+ ? { helperText: config.helperText }
3078
+ : {}),
3079
+ ...(config.output !== undefined ? { output: config.output } : {}),
3080
+ ...(entryFields !== undefined ? { entryFields } : {}),
3081
+ children: children as LayoutNode[],
3082
+ } as MultiPopulatorNode;
3083
+ }
3084
+
3085
+ function createOrConditionNode(
3086
+ options: {
3087
+ showTitle?: boolean;
3088
+ helperText?: string;
3089
+ button_label?: string;
3090
+ } = {},
3091
+ branches: OptionGroup[] = [],
3092
+ ): OrConditionNode {
3093
+ return {
3094
+ type: "or_condition",
3095
+ ...(options.showTitle !== undefined
3096
+ ? { showTitle: options.showTitle }
3097
+ : {}),
3098
+ ...(options.helperText !== undefined
3099
+ ? { helperText: options.helperText }
3100
+ : {}),
3101
+ ...(options.button_label !== undefined
3102
+ ? { button_label: options.button_label }
3103
+ : {}),
3104
+ branches,
3105
+ } as OrConditionNode;
3106
+ }
3107
+
3108
+ export const layoutContainers = {
3109
+ FormStep: createFormStepNode,
3110
+ ConditionalBlock: createConditionalBlockNode,
3111
+ MultiPopulator: createMultiPopulatorNode,
3112
+ OrCondition: createOrConditionNode,
3113
+ };
3114
+
3115
+ // ── createFormSchemaOrchestrator (0.9.50+) ────────────────────────────
3116
+
3117
+ function toSlug(value: string): string {
3118
+ return value
3119
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
3120
+ .replace(/[^a-zA-Z0-9]+/g, "-")
3121
+ .replace(/^-+|-+$/g, "")
3122
+ .toLowerCase();
3123
+ }
3124
+
3125
+ /**
3126
+ * Schema declarations use descriptive SDK element names; persisted custom
3127
+ * forms use the renderer's compact names. Keep the translation at the
3128
+ * artifact boundary so declarations and form storage do not leak into one
3129
+ * another.
3130
+ */
3131
+ function toPersistedFormElement(element: AllFormElements): string {
3132
+ const aliases: Partial<Record<AllFormElements, string>> = {
3133
+ short_text: "text",
3134
+ long_text: "textarea",
3135
+ dropdown: "select",
3136
+ radio_group: "radio",
3137
+ yes_no_toggle: "boolean",
3138
+ yes_no_checkbox: "boolean_checkbox",
3139
+ "multi-select": "multi_select",
3140
+ checkbox_group: "checkbox",
3141
+ checkbox_group_many: "checkbox",
3142
+ choice_cards_many: "choice_cards",
3143
+ services_selector: "service_selector",
3144
+ services_selector_many: "service_selector",
3145
+ phone_number: "phone",
3146
+ phone_number_basic: "phone",
3147
+ timePicker: "time_picker",
3148
+ "time_picker.date": "date",
3149
+ "time_picker.time": "time_picker",
3150
+ time_picker_timeslot: "time_picker",
3151
+ time_picker_datetime: "time_picker",
3152
+ address_group: "address",
3153
+ range: "slider",
3154
+ formFileValue: "file_upload",
3155
+ };
3156
+ return aliases[element] ?? element;
3157
+ }
3158
+
3159
+ function generateCollectionFormCandidates(
3160
+ objects: readonly Readonly<ObjectDefinition>[],
3161
+ options: FormOrchestrationOptions,
3162
+ ): GeneratedFormCandidate[] {
3163
+ // Indexed by BOTH universalIdentifier and nameSingular: `relationTarget` is
3164
+ // written by the author as a collection name and is only resolved to a uid
3165
+ // later in the pipeline. Looking up by uid alone silently found nothing, so a
3166
+ // relation rendered as a picker with no collection wired to it.
3167
+ const objectByIdentifier = new Map<string, Readonly<ObjectDefinition>>();
3168
+ for (const object of objects) {
3169
+ objectByIdentifier.set(object.universalIdentifier, object);
3170
+ objectByIdentifier.set(object.nameSingular, object);
3171
+ }
3172
+
3173
+ return objects.flatMap((object) => {
3174
+ const fieldMap: Record<string, string> = {};
3175
+ const formFields: GeneratedFormCandidate["fields"] = [];
3176
+
3177
+ for (const field of object.fields) {
3178
+ if (field.isPrimaryKey || field.isAutoIncrement || field.isPrivate)
3179
+ continue;
3180
+ const declaredElement =
3181
+ field.formElement ?? field.recommendedFormElements?.[0];
3182
+ if (!declaredElement) continue;
3183
+ const type = toPersistedFormElement(declaredElement);
3184
+
3185
+ const id = generateUniversalIdentifier(
3186
+ `biab:generated-form:${object.universalIdentifier}:field:${field.universalIdentifier}`,
3187
+ );
3188
+ fieldMap[field.name] = id;
3189
+ const relationTarget = field.relationTarget
3190
+ ? objectByIdentifier.get(field.relationTarget)
3191
+ : undefined;
3192
+ const regex = field.validation?.regex;
3193
+ const min =
3194
+ field.validation?.minLength ??
3195
+ (field.validation?.greaterThan !== undefined
3196
+ ? field.validation.greaterThan
3197
+ : undefined);
3198
+ const max =
3199
+ field.validation?.maxLength ??
3200
+ (field.validation?.lessThan !== undefined
3201
+ ? field.validation.lessThan
3202
+ : undefined);
3203
+
3204
+ formFields.push({
3205
+ id,
3206
+ label: field.label,
3207
+ type,
3208
+ required: field.isRequired === true || field.isNullable === false,
3209
+ outputKey: field.name,
3210
+ ...(field.options
3211
+ ? { options: field.options.map((option) => option.value) }
3212
+ : {}),
3213
+ ...(typeof field.defaultValue === "string" ||
3214
+ typeof field.defaultValue === "boolean"
3215
+ ? { defaultValue: field.defaultValue }
3216
+ : {}),
3217
+ ...(field.helperText ? { helpText: field.helperText } : {}),
3218
+ ...(regex
3219
+ ? {
3220
+ regexPattern: typeof regex === "string" ? regex : regex.source,
3221
+ }
3222
+ : {}),
3223
+ ...(min !== undefined || max !== undefined
3224
+ ? {
3225
+ validation: {
3226
+ ...(min !== undefined ? { min } : {}),
3227
+ ...(max !== undefined ? { max } : {}),
3228
+ },
3229
+ }
3230
+ : {}),
3231
+ ...(type === "collection_select" && relationTarget
3232
+ ? {
3233
+ collectionSelectConfig: {
3234
+ collectionKey: relationTarget.universalIdentifier,
3235
+ ...(relationTarget.labelIdentifierFieldName
3236
+ ? { labelField: relationTarget.labelIdentifierFieldName }
3237
+ : {}),
3238
+ valueMode: "id" as const,
3239
+ // The relation's cardinality decides the picker, so an author
3240
+ // never configures "multi" — declaring that a record can
3241
+ // reference several of something IS the declaration that the
3242
+ // picker takes several. The join table stays invisible.
3243
+ ...(field.relationCardinality === "many"
3244
+ ? { multi: true }
3245
+ : {}),
3246
+ },
3247
+ }
3248
+ : {}),
3249
+ });
3250
+ }
3251
+
3252
+ if (formFields.length === 0) return [];
3253
+ const sourceKey = `data-model:${object.universalIdentifier}`;
3254
+ return [
3255
+ {
3256
+ sourceKey,
3257
+ name: `${object.labelSingular} Form`,
3258
+ slug: `${toSlug(object.nameSingular)}-form`,
3259
+ description:
3260
+ object.description ??
3261
+ `Create one ${object.labelSingular.toLowerCase()} record.`,
3262
+ fields: formFields,
3263
+ settings: {
3264
+ isConsecutive: options.consecutive ?? false,
3265
+ showProgress: options.progress ?? options.consecutive ?? false,
3266
+ ...(options.progress_style
3267
+ ? {
3268
+ progressStyle:
3269
+ options.progress_style === "progress_bar"
3270
+ ? ("bar" as const)
3271
+ : ("steps" as const),
3272
+ }
3273
+ : {}),
3274
+ generatedFrom: {
3275
+ kind: "data_model" as const,
3276
+ sourceKey,
3277
+ objectIdentifier: object.universalIdentifier,
3278
+ objectIdentifiers: [object.universalIdentifier],
3279
+ },
3280
+ actions: [
3281
+ {
3282
+ id: generateUniversalIdentifier(
3283
+ `biab:generated-form:${object.universalIdentifier}:action:create-record`,
3284
+ ),
3285
+ type: "create_records" as const,
3286
+ enabled: true as const,
3287
+ mapping: {},
3288
+ config: {
3289
+ composite: {
3290
+ creates: [
3291
+ {
3292
+ alias: object.nameSingular,
3293
+ object: object.universalIdentifier,
3294
+ fieldMap,
3295
+ },
3296
+ ],
3297
+ },
3298
+ },
3299
+ },
3300
+ ],
3301
+ },
3302
+ },
3303
+ ];
3304
+ });
3305
+ }
3306
+
3307
+ // ─────────────────────────────────────────────────────────────────────────────
3308
+ // Single-resource action forms
3309
+ // ─────────────────────────────────────────────────────────────────────────────
3310
+
3311
+ /** The three atomic operations generated for one collection. */
3312
+ export interface SingleResourceForms {
3313
+ /** The collection this bundle operates on (its universalIdentifier). */
3314
+ collectionId: string;
3315
+ create: GeneratedFormCandidate;
3316
+ edit: GeneratedFormCandidate;
3317
+ delete: GeneratedFormCandidate;
3318
+ /**
3319
+ * Collections this one points at, so an operator can see at a glance what a
3320
+ * form depends on.
3321
+ *
3322
+ * INFORMATIONAL ONLY. Nothing needs to "inject" these: a relation field's
3323
+ * generated picker already carries `collectionSelectConfig.collectionKey`,
3324
+ * and options load live at render. This list is for humans, not plumbing.
3325
+ */
3326
+ dependencies: string[];
3327
+ }
3328
+
3329
+ export interface SingleResourceFormOptions extends FormOrchestrationOptions {
3330
+ /**
3331
+ * Sibling collections, used ONLY to describe what a delete would cascade to.
3332
+ * Omit and the delete form makes no cascade claim at all — silence beats a
3333
+ * confident sentence that might be wrong about someone's data.
3334
+ */
3335
+ relatedObjects?: readonly Readonly<ObjectDefinition>[];
3336
+ }
3337
+
3338
+ /** A record picker for one collection — "which row are we acting on?". */
3339
+ function recordIdentityField(
3340
+ object: Readonly<ObjectDefinition>,
3341
+ purpose: "edit" | "delete",
3342
+ ): GeneratedFormCandidate["fields"][number] {
3343
+ return {
3344
+ id: generateUniversalIdentifier(
3345
+ `biab:generated-form:${object.universalIdentifier}:${purpose}:record`,
3346
+ ),
3347
+ label: object.labelSingular,
3348
+ type: "collection_select",
3349
+ required: true,
3350
+ outputKey: "recordId",
3351
+ helpText: `Which ${object.labelSingular.toLowerCase()} to ${purpose}.`,
3352
+ collectionSelectConfig: {
3353
+ collectionKey: object.universalIdentifier,
3354
+ ...(object.labelIdentifierFieldName
3355
+ ? { labelField: object.labelIdentifierFieldName }
3356
+ : {}),
3357
+ valueMode: "id" as const,
3358
+ },
3359
+ };
3360
+ }
3361
+
3362
+ /**
3363
+ * What deleting one record of this collection would take with it.
3364
+ *
3365
+ * Read from the relations that actually point AT this collection and their
3366
+ * declared `onDelete`, rather than asserting a generic "respects referential
3367
+ * integrity" — which would be a promise the model may not keep.
3368
+ */
3369
+ function describeDeleteImpact(
3370
+ object: Readonly<ObjectDefinition>,
3371
+ related: readonly Readonly<ObjectDefinition>[],
3372
+ ): string {
3373
+ const cascades: string[] = [];
3374
+ const blocks: string[] = [];
3375
+ // `relationTarget` is written by the author as a collection NAME and only
3376
+ // resolved to a universalIdentifier later in the pipeline, so match on either
3377
+ // — otherwise this silently finds nothing and every delete looks consequence-free.
3378
+ const identifies = (target: string | undefined) =>
3379
+ target === object.universalIdentifier || target === object.nameSingular;
3380
+ for (const other of related) {
3381
+ if (other.universalIdentifier === object.universalIdentifier) continue;
3382
+ for (const field of other.fields) {
3383
+ if (!identifies(field.relationTarget)) continue;
3384
+ if (field.onDelete === "cascade") cascades.push(other.labelPlural);
3385
+ else if ((field.onDelete ?? "restrict") === "restrict") {
3386
+ blocks.push(other.labelPlural);
3387
+ }
3388
+ }
3389
+ }
3390
+ const parts = [
3391
+ `Permanently remove this ${object.labelSingular.toLowerCase()}.`,
3392
+ ];
3393
+ if (cascades.length > 0) {
3394
+ parts.push(
3395
+ `Linked ${[...new Set(cascades)].join(", ")} will be deleted with it.`,
3396
+ );
3397
+ }
3398
+ if (blocks.length > 0) {
3399
+ parts.push(
3400
+ `It can't be deleted while ${[...new Set(blocks)].join(", ")} still reference it.`,
3401
+ );
3402
+ }
3403
+ return parts.join(" ");
3404
+ }
3405
+
3406
+ /**
3407
+ * Generate the three atomic, single-record forms for one collection — Create,
3408
+ * Edit and Delete — instead of one big multi-step wizard.
3409
+ *
3410
+ * Relation fields become live collection pickers automatically, single or
3411
+ * multi according to the relation's own cardinality, so a many-to-many is
3412
+ * filled in inline and the join table never surfaces as a concept.
3413
+ *
3414
+ * Lineage: `create` deliberately KEEPS the base `data-model:<uid>` sourceKey
3415
+ * that the one-form-per-collection generator has always used, so promoting a
3416
+ * model that previously produced a single form updates that draft rather than
3417
+ * orphaning it and creating a duplicate. `edit` and `delete` take suffixed keys
3418
+ * off the same stem.
3419
+ */
3420
+ export function createSingleResourceForm(
3421
+ object: Readonly<ObjectDefinition>,
3422
+ options: SingleResourceFormOptions = {},
3423
+ ): SingleResourceForms {
3424
+ const { relatedObjects, ...orchestration } = options;
3425
+ // Reuse the existing per-collection generator rather than re-deriving field
3426
+ // mapping, element choice and picker config — one place decides how a
3427
+ // collection becomes a form.
3428
+ //
3429
+ // Siblings are passed in so relation targets RESOLVE: a picker needs its
3430
+ // target's identifier and label field, and without them in the map the field
3431
+ // renders as a picker wired to nothing. `.filter` keeps the subject first and
3432
+ // prevents a duplicate candidate for it.
3433
+ const generatorInput = [
3434
+ object,
3435
+ ...(relatedObjects ?? []).filter(
3436
+ (o) => o.universalIdentifier !== object.universalIdentifier,
3437
+ ),
3438
+ ];
3439
+ const generated = generateCollectionFormCandidates(
3440
+ generatorInput,
3441
+ orchestration,
3442
+ );
3443
+ const base = generated.find(
3444
+ (c) =>
3445
+ c.settings.generatedFrom.objectIdentifier === object.universalIdentifier,
3446
+ );
3447
+ if (!base) {
3448
+ throw new Error(
3449
+ `createSingleResourceForm: "${object.nameSingular}" has no form-renderable fields.`,
3450
+ );
3451
+ }
3452
+
3453
+ const human = object.labelSingular;
3454
+ const slugBase = toSlug(object.nameSingular);
3455
+
3456
+ const create: GeneratedFormCandidate = {
3457
+ ...base,
3458
+ name: `Add ${human}`,
3459
+ description: `Create one ${human.toLowerCase()}.`,
3460
+ };
3461
+
3462
+ // Edit reuses create's fields, prefixed by a picker for WHICH record. The
3463
+ // record's id is identity, not an answer — it's a picker, never a text box
3464
+ // someone can retype into a different row.
3465
+ const editSourceKey = `${base.sourceKey}:edit`;
3466
+ const edit: GeneratedFormCandidate = {
3467
+ ...base,
3468
+ sourceKey: editSourceKey,
3469
+ name: `Edit ${human}`,
3470
+ slug: `${slugBase}-edit`,
3471
+ description: `Update an existing ${human.toLowerCase()}.`,
3472
+ fields: [recordIdentityField(object, "edit"), ...base.fields],
3473
+ settings: {
3474
+ ...base.settings,
3475
+ generatedFrom: {
3476
+ ...base.settings.generatedFrom,
3477
+ sourceKey: editSourceKey,
3478
+ },
3479
+ },
3480
+ };
3481
+
3482
+ // Delete carries no data fields — a picker and a plain statement of
3483
+ // consequences. It intentionally keeps no `create_records` action: this form
3484
+ // removes a row, it doesn't make one.
3485
+ const deleteSourceKey = `${base.sourceKey}:delete`;
3486
+ const del: GeneratedFormCandidate = {
3487
+ ...base,
3488
+ sourceKey: deleteSourceKey,
3489
+ name: `Delete ${human}`,
3490
+ slug: `${slugBase}-delete`,
3491
+ description: describeDeleteImpact(object, relatedObjects ?? []),
3492
+ fields: [recordIdentityField(object, "delete")],
3493
+ settings: {
3494
+ ...base.settings,
3495
+ generatedFrom: {
3496
+ ...base.settings.generatedFrom,
3497
+ sourceKey: deleteSourceKey,
3498
+ },
3499
+ actions: [],
3500
+ },
3501
+ };
3502
+
3503
+ // Relation targets, as declared. Informational — the generated pickers already
3504
+ // carry their own `collectionKey`, so nothing consumes this to wire anything.
3505
+ const dependencies = [
3506
+ ...new Set(
3507
+ object.fields
3508
+ .map((f) => f.relationTarget)
3509
+ .filter((t): t is string => typeof t === "string"),
3510
+ ),
3511
+ ];
3512
+
3513
+ return {
3514
+ collectionId: object.universalIdentifier,
3515
+ create,
3516
+ edit,
3517
+ delete: del,
3518
+ dependencies,
3519
+ };
3520
+ }
3521
+
3522
+ function isCollectionDefinition(node: unknown): node is CollectionDefinition {
3523
+ // No casts: `typeof node === "object" && node !== null` narrows to `object`,
3524
+ // and `in` operates on that directly.
3525
+ return (
3526
+ typeof node === "object" &&
3527
+ node !== null &&
3528
+ !("type" in node) &&
3529
+ "nameSingular" in node &&
3530
+ "fields" in node
3531
+ );
3532
+ }
3533
+
3534
+ function isSchemaBoundaryNode(node: unknown): node is SchemaBoundaryNode {
3535
+ return (
3536
+ typeof node === "object" &&
3537
+ node !== null &&
3538
+ "type" in node &&
3539
+ node.type === "schema_boundary"
3540
+ );
3541
+ }
3542
+
3543
+ function walkTreeForCollections(
3544
+ boundaries: (SchemaBoundaryNode | CollectionDefinition)[],
3545
+ collections: CollectionDefinition[],
3546
+ featureTags: Record<string, string>,
3547
+ inheritedFeatureId?: string,
3548
+ ): LayoutNode[] {
3549
+ const layoutNodes: LayoutNode[] = [];
3550
+
3551
+ for (const node of boundaries) {
3552
+ if (isSchemaBoundaryNode(node)) {
3553
+ const fid = node.meta.featureId;
3554
+ const children = walkTreeForCollections(
3555
+ node.children as (SchemaBoundaryNode | CollectionDefinition)[],
3556
+ collections,
3557
+ featureTags,
3558
+ fid,
3559
+ );
3560
+ layoutNodes.push(...children);
3561
+ } else if (isCollectionDefinition(node)) {
3562
+ collections.push(node);
3563
+ if (inheritedFeatureId) {
3564
+ featureTags[node.nameSingular] = inheritedFeatureId;
3565
+ }
3566
+ layoutNodes.push({
3567
+ type: "render_collection",
3568
+ collectionId: node.nameSingular,
3569
+ });
3570
+ } else {
3571
+ const layoutNode = node as LayoutNode & { branches?: OptionGroup[] };
3572
+ if (
3573
+ layoutNode.type === "form_step" ||
3574
+ layoutNode.type === "conditional_block" ||
3575
+ layoutNode.type === "multi_populator"
3576
+ ) {
3577
+ const children = walkTreeForCollections(
3578
+ (layoutNode.children || []) as unknown as (
3579
+ | SchemaBoundaryNode
3580
+ | CollectionDefinition
3581
+ )[],
3582
+ collections,
3583
+ featureTags,
3584
+ inheritedFeatureId,
3585
+ );
3586
+ layoutNodes.push({ ...layoutNode, children } as LayoutNode);
3587
+ } else if (layoutNode.type === "or_condition") {
3588
+ const branches = (layoutNode.branches || []).map((branch) => ({
3589
+ ...branch,
3590
+ nodes: walkTreeForCollections(
3591
+ branch.nodes as unknown as (
3592
+ | SchemaBoundaryNode
3593
+ | CollectionDefinition
3594
+ )[],
3595
+ collections,
3596
+ featureTags,
3597
+ inheritedFeatureId,
3598
+ ),
3599
+ }));
3600
+ layoutNodes.push({ ...layoutNode, branches } as LayoutNode);
3601
+ } else if (layoutNode.type === "render_collection") {
3602
+ const col = collections.find(
3603
+ (c) => c.nameSingular === layoutNode.collectionId,
3604
+ );
3605
+ if (!col && collections.length > 0) {
3606
+ throw new Error(
3607
+ `[BusinessDash SDK] Layout references unknown collection "${layoutNode.collectionId}".`,
3608
+ );
3609
+ }
3610
+ layoutNodes.push(layoutNode as LayoutNode);
3611
+ }
3612
+ }
3613
+ }
3614
+
3615
+ return layoutNodes;
3616
+ }
3617
+
3618
+ /**
3619
+ * Compile a layout tree into a pure database schema + UI form tree.
3620
+ *
3621
+ * Unlike the previous API which required separate `collections` and `layout`
3622
+ * properties, this new signature accepts a flat array of `SchemaBoundary`,
3623
+ * `collection()` definitions, and layout containers — extracting collections
3624
+ * and the visual render graph from the same expression tree.
3625
+ *
3626
+ * ```ts
3627
+ * const result = createFormSchemaOrchestrator([
3628
+ * SchemaBoundary(
3629
+ * { featureId: "crm", name: "CRM" },
3630
+ * collection("contacts", { fields: { name: bd.text().required() } }),
3631
+ * ),
3632
+ * ], { defaultIdType: "uuid", consecutive: true });
3633
+ * ```
3634
+ */
3635
+ export function createFormSchemaOrchestrator(
3636
+ boundaries: (SchemaBoundaryNode | CollectionDefinition | LayoutNode)[],
3637
+ options: FormOrchestrationOptions = {},
3638
+ ): {
3639
+ databaseSchema: DataModelClientShape;
3640
+ uiFormTree: LayoutNode[];
3641
+ /**
3642
+ * Inactive form drafts the CLI can include with the schema proposal.
3643
+ * Every field uses its explicit formElement or the first compatible
3644
+ * recommendedFormElements entry.
3645
+ */
3646
+ generatedForms: GeneratedFormCandidate[];
3647
+ formControlSettings: {
3648
+ consecutive: boolean;
3649
+ progress: boolean;
3650
+ progressStyle?: ProgressStyle;
3651
+ };
3652
+ featureTags: Record<string, string>;
3653
+ } {
3654
+ if (!Array.isArray(boundaries)) {
3655
+ throw new Error(
3656
+ "[BusinessDash SDK] createFormSchemaOrchestrator: `boundaries` must be an array of SchemaBoundaryNode | CollectionDefinition | LayoutNode.",
3657
+ );
3658
+ }
3659
+ const collections: CollectionDefinition[] = [];
3660
+ const featureTags: Record<string, string> = {};
3661
+ const uiFormTree = walkTreeForCollections(
3662
+ boundaries as (SchemaBoundaryNode | CollectionDefinition)[],
3663
+ collections,
3664
+ featureTags,
3665
+ );
3666
+
3667
+ if (collections.length === 0) {
3668
+ throw new Error(
3669
+ "[BusinessDash SDK] createFormSchemaOrchestrator: No collections found. Pass collection() directly, inside an optional SchemaBoundary(), or under an optional layout container.",
3670
+ );
3671
+ }
3672
+
3673
+ const schemaOptions: SchemaOptions =
3674
+ options.defaultIdType !== undefined
3675
+ ? { defaultIdType: options.defaultIdType }
3676
+ : {};
3677
+
3678
+ const compiledDatabaseSchema = createSchema({ collections }, schemaOptions);
3679
+
3680
+ const dbCollectionMap = new Map<
3681
+ string,
3682
+ { name: string; fields: FieldDefinition[] }
3683
+ >();
3684
+ for (const col of compiledDatabaseSchema.objects) {
3685
+ dbCollectionMap.set(
3686
+ col.nameSingular,
3687
+ col as unknown as { name: string; fields: FieldDefinition[] },
3688
+ );
3689
+ }
3690
+
3691
+ function renderedCollectionIds(nodes: LayoutNode[]): string[] {
3692
+ const ids: string[] = [];
3693
+ for (const node of nodes) {
3694
+ if (node.type === "render_collection") {
3695
+ ids.push(node.collectionId);
3696
+ } else if (
3697
+ node.type === "form_step" ||
3698
+ node.type === "conditional_block" ||
3699
+ node.type === "multi_populator"
3700
+ ) {
3701
+ ids.push(...renderedCollectionIds(node.children));
3702
+ } else if (node.type === "or_condition") {
3703
+ for (const branch of node.branches) {
3704
+ ids.push(...renderedCollectionIds(branch.nodes as LayoutNode[]));
3705
+ }
3706
+ }
3707
+ }
3708
+ return ids;
3709
+ }
3710
+
3711
+ function generatedLayoutForms(
3712
+ nodes: LayoutNode[],
3713
+ collectionForms: GeneratedFormCandidate[],
3714
+ ): GeneratedFormCandidate[] {
3715
+ const generated: GeneratedFormCandidate[] = [];
3716
+ const objectByName = new Map(
3717
+ compiledDatabaseSchema.objects.map((object) => [
3718
+ object.nameSingular,
3719
+ object,
3720
+ ]),
3721
+ );
3722
+ const formByObjectIdentifier = new Map(
3723
+ collectionForms.map((form) => [
3724
+ form.settings.generatedFrom.objectIdentifier,
3725
+ form,
3726
+ ]),
3727
+ );
3728
+
3729
+ const walk = (candidates: LayoutNode[]) => {
3730
+ for (const node of candidates) {
3731
+ if (node.type === "form_step") {
3732
+ const collectionIds = [
3733
+ ...new Set(renderedCollectionIds(node.children)),
3734
+ ];
3735
+ // A one-collection step is already represented by its collection
3736
+ // candidate. Composite steps are additional CLI choices.
3737
+ if (collectionIds.length > 1) {
3738
+ const objects = collectionIds
3739
+ .map((id) => objectByName.get(id))
3740
+ .filter(
3741
+ (object): object is Readonly<ObjectDefinition> =>
3742
+ object !== undefined,
3743
+ );
3744
+ const identifiers = new Set(
3745
+ objects.map((object) => object.universalIdentifier),
3746
+ );
3747
+ const aliasByIdentifier = new Map(
3748
+ objects.map((object) => [
3749
+ object.universalIdentifier,
3750
+ object.nameSingular,
3751
+ ]),
3752
+ );
3753
+ const fields: GeneratedFormCandidate["fields"] = [];
3754
+ const creates: GeneratedFormCandidate["settings"]["actions"][number]["config"]["composite"]["creates"] =
3755
+ [];
3756
+
3757
+ for (const object of objects) {
3758
+ const base = formByObjectIdentifier.get(
3759
+ object.universalIdentifier,
3760
+ );
3761
+ if (!base) continue;
3762
+ const fieldMap = {
3763
+ ...base.settings.actions[0]!.config.composite.creates[0]!
3764
+ .fieldMap,
3765
+ };
3766
+ const links: Array<{ field: string; to: string }> = [];
3767
+
3768
+ for (const schemaField of object.fields) {
3769
+ if (
3770
+ schemaField.type !== "RELATION" ||
3771
+ !schemaField.relationTarget ||
3772
+ !identifiers.has(schemaField.relationTarget)
3773
+ ) {
3774
+ continue;
3775
+ }
3776
+ delete fieldMap[schemaField.name];
3777
+ const targetAlias = aliasByIdentifier.get(
3778
+ schemaField.relationTarget,
3779
+ );
3780
+ if (targetAlias) {
3781
+ links.push({
3782
+ field: schemaField.name,
3783
+ to: `$${targetAlias}`,
3784
+ });
3785
+ }
3786
+ }
3787
+
3788
+ for (const field of base.fields) {
3789
+ if (!(field.outputKey in fieldMap)) continue;
3790
+ const outputKey = `${object.nameSingular}${field.outputKey
3791
+ .charAt(0)
3792
+ .toUpperCase()}${field.outputKey.slice(1)}`;
3793
+ fields.push({
3794
+ ...field,
3795
+ label: `${object.labelSingular}: ${field.label}`,
3796
+ outputKey,
3797
+ });
3798
+ }
3799
+ creates.push({
3800
+ alias: object.nameSingular,
3801
+ object: object.universalIdentifier,
3802
+ fieldMap,
3803
+ ...(links.length > 0 ? { links } : {}),
3804
+ });
3805
+ }
3806
+
3807
+ if (fields.length > 0 && creates.length > 1) {
3808
+ const sourceKey = `data-model-layout:${node.name}`;
3809
+ generated.push({
3810
+ sourceKey,
3811
+ name: `${toLabel(node.name)} Form`,
3812
+ slug: `${toSlug(node.name)}-form`,
3813
+ description:
3814
+ node.helperText ??
3815
+ `Create related ${objects
3816
+ .map((object) => object.labelPlural.toLowerCase())
3817
+ .join(", ")} in one submission.`,
3818
+ fields,
3819
+ settings: {
3820
+ isConsecutive: options.consecutive ?? false,
3821
+ showProgress:
3822
+ options.progress ?? options.consecutive ?? false,
3823
+ ...(options.progress_style
3824
+ ? {
3825
+ progressStyle:
3826
+ options.progress_style === "progress_bar"
3827
+ ? ("bar" as const)
3828
+ : ("steps" as const),
3829
+ }
3830
+ : {}),
3831
+ generatedFrom: {
3832
+ kind: "data_model",
3833
+ sourceKey,
3834
+ objectIdentifiers: objects.map(
3835
+ (object) => object.universalIdentifier,
3836
+ ),
3837
+ },
3838
+ actions: [
3839
+ {
3840
+ id: generateUniversalIdentifier(
3841
+ `biab:generated-form:${sourceKey}:action:create-records`,
3842
+ ),
3843
+ type: "create_records",
3844
+ enabled: true,
3845
+ mapping: {},
3846
+ config: { composite: { creates } },
3847
+ },
3848
+ ],
3849
+ },
3850
+ });
3851
+ }
3852
+ }
3853
+ walk(node.children);
3854
+ } else if (
3855
+ node.type === "conditional_block" ||
3856
+ node.type === "multi_populator"
3857
+ ) {
3858
+ walk(node.children);
3859
+ } else if (node.type === "or_condition") {
3860
+ for (const branch of node.branches) {
3861
+ walk(branch.nodes as LayoutNode[]);
3862
+ }
3863
+ }
3864
+ }
3865
+ };
3866
+ walk(nodes);
3867
+ return generated;
3868
+ }
3869
+
3870
+ function validateAndAlignLayout(nodes: LayoutNode[]) {
3871
+ for (const node of nodes) {
3872
+ if (node.type === "render_collection") {
3873
+ if (!dbCollectionMap.has(node.collectionId)) {
3874
+ throw new Error(
3875
+ `[BusinessDash SDK] Form layout references collection "${node.collectionId}" ` +
3876
+ "which does not exist in the declared collections block.",
3877
+ );
3878
+ }
3879
+ }
3880
+
3881
+ if (node.type === "conditional_block") {
3882
+ for (const rule of node.rules) {
3883
+ const parts = rule.fieldRef.split(".");
3884
+ if (parts.length !== 2) {
3885
+ throw new Error(
3886
+ `[BusinessDash SDK] Conditional rule fieldRef "${rule.fieldRef}" must be in "collectionName.fieldName" format.`,
3887
+ );
3888
+ }
3889
+ const targetCollection = parts[0]!;
3890
+ const targetField = parts[1]!;
3891
+ const collectionDef = dbCollectionMap.get(targetCollection);
3892
+ if (!collectionDef) {
3893
+ throw new Error(
3894
+ `[BusinessDash SDK] Conditional rule references collection "${targetCollection}" in fieldRef "${rule.fieldRef}" which does not exist.`,
3895
+ );
3896
+ }
3897
+ const field = collectionDef.fields.find(
3898
+ (f) => f.name === targetField,
3899
+ );
3900
+ if (!field) {
3901
+ throw new Error(
3902
+ `[BusinessDash SDK] Conditional rule references field "${targetField}" in collection "${targetCollection}" which does not exist.`,
3903
+ );
3904
+ }
3905
+ }
3906
+ }
3907
+
3908
+ if (node.type === "multi_populator") {
3909
+ if (
3910
+ node.max_entries !== undefined &&
3911
+ (!Number.isInteger(node.max_entries) || node.max_entries < 1)
3912
+ ) {
3913
+ throw new Error(
3914
+ "[BusinessDash SDK] MultiPopulator max_entries must be a positive whole number.",
3915
+ );
3916
+ }
3917
+
3918
+ const renderedIds = [...new Set(renderedCollectionIds(node.children))];
3919
+ const output = node.output;
3920
+
3921
+ if (!output) {
3922
+ if (renderedIds.length !== 1) {
3923
+ throw new Error(
3924
+ "[BusinessDash SDK] MultiPopulator without an explicit output must contain exactly one child collection so separate-table storage can be inferred.",
3925
+ );
3926
+ }
3927
+ node.output = {
3928
+ strategy: "separateTable",
3929
+ collectionId: renderedIds[0]!,
3930
+ };
3931
+ } else if (output.strategy === "separateTable") {
3932
+ if (node.entryFields !== undefined) {
3933
+ throw new Error(
3934
+ "[BusinessDash SDK] MultiPopulator entryFields are only valid for JSONB output. Define secondary-table fields on the target collection.",
3935
+ );
3936
+ }
3937
+
3938
+ const collectionId =
3939
+ output.collectionId ??
3940
+ (renderedIds.length === 1 ? renderedIds[0] : undefined);
3941
+ if (!collectionId) {
3942
+ throw new Error(
3943
+ "[BusinessDash SDK] MultiPopulator separateTable output requires collectionId unless exactly one child collection can be inferred.",
3944
+ );
3945
+ }
3946
+ if (!dbCollectionMap.has(collectionId)) {
3947
+ throw new Error(
3948
+ `[BusinessDash SDK] MultiPopulator separateTable output references collection "${collectionId}" which does not exist.`,
3949
+ );
3950
+ }
3951
+ if (renderedIds.length > 0 && !renderedIds.includes(collectionId)) {
3952
+ throw new Error(
3953
+ `[BusinessDash SDK] MultiPopulator separateTable output targets "${collectionId}", but that collection is not rendered by the repeater.`,
3954
+ );
3955
+ }
3956
+ node.output = { strategy: "separateTable", collectionId };
3957
+ } else {
3958
+ if (node.children.length > 0) {
3959
+ throw new Error(
3960
+ "[BusinessDash SDK] MultiPopulator JSONB output defines its repeated shape with entryFields and cannot also contain child collections or layout nodes.",
3961
+ );
3962
+ }
3963
+ if (!node.entryFields || node.entryFields.length === 0) {
3964
+ throw new Error(
3965
+ "[BusinessDash SDK] MultiPopulator JSONB output requires at least one entryFields declaration.",
3966
+ );
3967
+ }
3968
+
3969
+ const parts = output.fieldRef.split(".");
3970
+ if (parts.length !== 2) {
3971
+ throw new Error(
3972
+ `[BusinessDash SDK] MultiPopulator JSONB fieldRef "${output.fieldRef}" must be in "collectionName.fieldName" format.`,
3973
+ );
3974
+ }
3975
+ const targetCollection = parts[0]!;
3976
+ const targetField = parts[1]!;
3977
+ const collectionDef = dbCollectionMap.get(targetCollection);
3978
+ if (!collectionDef) {
3979
+ throw new Error(
3980
+ `[BusinessDash SDK] MultiPopulator JSONB output references collection "${targetCollection}" which does not exist.`,
3981
+ );
3982
+ }
3983
+ const field = collectionDef.fields.find(
3984
+ (candidate) => candidate.name === targetField,
3985
+ );
3986
+ if (!field) {
3987
+ throw new Error(
3988
+ `[BusinessDash SDK] MultiPopulator JSONB output references field "${targetField}" in collection "${targetCollection}" which does not exist.`,
3989
+ );
3990
+ }
3991
+ if (field.type !== "RAW_JSON" && field.type !== "ARRAY") {
3992
+ throw new Error(
3993
+ `[BusinessDash SDK] MultiPopulator JSONB output field "${output.fieldRef}" must use bd.json(), bd.object(), or bd.array(); received "${field.type}".`,
3994
+ );
3995
+ }
3996
+ if (field.strategy !== "jsonb") {
3997
+ throw new Error(
3998
+ `[BusinessDash SDK] MultiPopulator JSONB output field "${output.fieldRef}" must declare .strategy("jsonb").`,
3999
+ );
4000
+ }
4001
+ }
4002
+ }
4003
+
4004
+ if (
4005
+ node.type === "form_step" ||
4006
+ node.type === "conditional_block" ||
4007
+ node.type === "multi_populator"
4008
+ ) {
4009
+ validateAndAlignLayout(node.children);
4010
+ } else if (node.type === "or_condition") {
4011
+ for (const branch of node.branches) {
4012
+ validateAndAlignLayout(branch.nodes as LayoutNode[]);
4013
+ }
4014
+ }
4015
+ }
4016
+ }
4017
+
4018
+ validateAndAlignLayout(uiFormTree);
4019
+
4020
+ const progress = options.progress ?? !!options.consecutive;
4021
+
4022
+ const progressStyle =
4023
+ options.progress_style ?? (progress ? "multi-step" : undefined);
4024
+ const collectionForms = generateCollectionFormCandidates(
4025
+ compiledDatabaseSchema.objects,
4026
+ options,
4027
+ );
4028
+ return {
4029
+ databaseSchema: compiledDatabaseSchema,
4030
+ uiFormTree,
4031
+ generatedForms: [
4032
+ ...generatedLayoutForms(uiFormTree, collectionForms),
4033
+ ...collectionForms,
4034
+ ],
4035
+ formControlSettings: {
4036
+ consecutive: options.consecutive ?? false,
4037
+ progress,
4038
+ ...(progressStyle !== undefined ? { progressStyle } : {}),
4039
+ },
4040
+ featureTags,
4041
+ };
4042
+ }