@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,2295 @@
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
+ export const FIELD_TYPES = [
21
+ "TEXT",
22
+ "NUMBER",
23
+ "NUMERIC",
24
+ "BOOLEAN",
25
+ "DATE",
26
+ "DATE_TIME",
27
+ "UUID",
28
+ "SELECT",
29
+ "MULTI_SELECT",
30
+ "RATING",
31
+ "POSITION",
32
+ "CURRENCY",
33
+ "EMAILS",
34
+ "PHONES",
35
+ "LINKS",
36
+ "ADDRESS",
37
+ "FULL_NAME",
38
+ "ACTOR",
39
+ "RELATION",
40
+ "MORPH_RELATION",
41
+ "FILES",
42
+ "RICH_TEXT",
43
+ "RAW_JSON",
44
+ "ARRAY",
45
+ "TS_VECTOR",
46
+ ];
47
+ export function isFieldType(value) {
48
+ return FIELD_TYPES.includes(value);
49
+ }
50
+ /** Create a field builder with chainable methods. */
51
+ function createFieldBuilder(initialState) {
52
+ const state = { ...initialState };
53
+ const builder = {
54
+ required() {
55
+ state.required = true;
56
+ state.nullable = false;
57
+ return builder;
58
+ },
59
+ optional() {
60
+ state.required = false;
61
+ state.nullable = true;
62
+ return builder;
63
+ },
64
+ nullable(value = true) {
65
+ state.nullable = value;
66
+ return builder;
67
+ },
68
+ default(value) {
69
+ state.defaultValue = value;
70
+ return builder;
71
+ },
72
+ indexed(value = true) {
73
+ state.indexed = value;
74
+ return builder;
75
+ },
76
+ unique(value = true) {
77
+ state.unique = value;
78
+ return builder;
79
+ },
80
+ description(text) {
81
+ state.description = text;
82
+ return builder;
83
+ },
84
+ helper(text) {
85
+ state.helperText = text;
86
+ return builder;
87
+ },
88
+ formElement(element, metadata) {
89
+ state.formElement = element;
90
+ state.elementMetadata = metadata;
91
+ return builder;
92
+ },
93
+ validate(rules) {
94
+ state.validation = { ...state.validation, ...rules };
95
+ return builder;
96
+ },
97
+ dynamicOptions(config) {
98
+ state.dynamicOptions = config;
99
+ return builder;
100
+ },
101
+ requiredIf(condition) {
102
+ state.validation = { ...state.validation, requiredIf: condition };
103
+ return builder;
104
+ },
105
+ regex(pattern) {
106
+ state.validation = { ...state.validation, regex: pattern };
107
+ return builder;
108
+ },
109
+ check(sqlExpression) {
110
+ state.validation = {
111
+ ...state.validation,
112
+ checkExpression: sqlExpression,
113
+ };
114
+ return builder;
115
+ },
116
+ strategy(mode, separateTableName) {
117
+ state.strategy = mode;
118
+ const meta = state.elementMetadata;
119
+ if (meta && "properties" in meta) {
120
+ meta.strategy = mode;
121
+ if (separateTableName)
122
+ meta.separateTableName = separateTableName;
123
+ }
124
+ if (separateTableName)
125
+ state.separateTableName = separateTableName;
126
+ return builder;
127
+ },
128
+ encrypted() {
129
+ state.encryption = "user";
130
+ return builder;
131
+ },
132
+ encryptedLight() {
133
+ state.encryption = "master";
134
+ return builder;
135
+ },
136
+ private() {
137
+ state.isPrivate = true;
138
+ return builder;
139
+ },
140
+ references(targetCollection, onDelete = "restrict") {
141
+ state.relationMeta = { type: "one-to-many", targetCollection };
142
+ builder.__relationMeta = state.relationMeta;
143
+ state.type = "RELATION";
144
+ state.relationTarget = targetCollection;
145
+ // A reference holds ONE target — picking another replaces it, rather
146
+ // than quietly accumulating into a set. (Relations promoted before
147
+ // cardinality existed carry none and keep the old "many" behaviour, so
148
+ // this only applies to models pushed from here on.)
149
+ state.relationCardinality = "one";
150
+ state.onDelete = onDelete;
151
+ return builder;
152
+ },
153
+ belongsToMany(targetCollection, options) {
154
+ state.relationMeta = {
155
+ type: "many-to-many",
156
+ targetCollection,
157
+ ...(options?.joinCollectionName !== undefined
158
+ ? { joinCollectionName: options.joinCollectionName }
159
+ : {}),
160
+ ...(options?.fields !== undefined ? { fields: options.fields } : {}),
161
+ };
162
+ if (state.relationMeta)
163
+ builder.__relationMeta = state.relationMeta;
164
+ // "RELATION", not the "__relation__" placeholder: the placeholder isn't a
165
+ // real FieldType, so `getRecommendedFormElements` returned nothing for it
166
+ // and the generator skipped the field for having no element to render.
167
+ // A many-to-many is a relation like any other — same type as
168
+ // `.references()`, distinguished only by its cardinality.
169
+ state.type = "RELATION";
170
+ // A many-to-many is still a RELATION pointing at a target — without
171
+ // this the built field carried no `relationTarget` and the server
172
+ // rejected the whole push ("RELATION requires relationTarget"), so
173
+ // belongsToMany type-checked but could never be applied.
174
+ state.relationTarget = targetCollection;
175
+ state.relationCardinality = "many";
176
+ // Values belonging to the PAIRING become typed attributes on the link.
177
+ // Only assign when there are any — `exactOptionalPropertyTypes` treats
178
+ // an explicit `undefined` as different from an absent key.
179
+ const linkAttrs = compileLinkAttributes(options?.fields);
180
+ if (linkAttrs)
181
+ state.linkAttributes = linkAttrs;
182
+ return builder;
183
+ },
184
+ onDelete(action) {
185
+ if (!state.relationMeta) {
186
+ throw new Error("onDelete() requires a relationship — chain it after .references() or .belongsToMany().");
187
+ }
188
+ state.relationMeta.onDeleteAction = action;
189
+ builder.__relationMeta = state.relationMeta;
190
+ if (action !== "NO ACTION") {
191
+ state.onDelete = DELETE_ACTION_TO_ON_DELETE[action];
192
+ }
193
+ else {
194
+ delete state.onDelete;
195
+ }
196
+ return builder;
197
+ },
198
+ build() {
199
+ return buildFieldDefinition(state);
200
+ },
201
+ };
202
+ return builder;
203
+ }
204
+ /** Extract relation metadata from a builder (used by collection()). */
205
+ function getRelationMeta(builder) {
206
+ return builder.__relationMeta;
207
+ }
208
+ /** Convert snake_case or camelCase to Title Case for labels. */
209
+ function toLabel(text) {
210
+ // Split on underscores, hyphens, or camelCase boundaries
211
+ return text
212
+ .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase -> "camel Case"
213
+ .replace(/[_-]+/g, " ") // snake_case, kebab-case -> "snake case"
214
+ .trim()
215
+ .split(/\s+/)
216
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
217
+ .join(" ");
218
+ }
219
+ /**
220
+ * Generate a universal identifier.
221
+ *
222
+ * A declaration is evaluated on every CLI run, so identities derived from
223
+ * collection/field names MUST be deterministic. Otherwise an unchanged config
224
+ * looks like a drop-and-recreate migration. Call without a seed only for the
225
+ * legacy object API, where callers already provide explicit identities.
226
+ */
227
+ function generateUniversalIdentifier(seed) {
228
+ if (seed !== undefined) {
229
+ const words = [2166136261, 2246822519, 3266489917, 668265263];
230
+ for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
231
+ let hash = words[wordIndex];
232
+ for (let index = 0; index < seed.length; index++) {
233
+ hash ^= seed.charCodeAt(index) + wordIndex * 31;
234
+ hash = Math.imul(hash, 16777619 + wordIndex * 2);
235
+ }
236
+ words[wordIndex] = hash >>> 0;
237
+ }
238
+ const hex = words
239
+ .map((word) => word.toString(16).padStart(8, "0"))
240
+ .join("");
241
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
242
+ }
243
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
244
+ return crypto.randomUUID();
245
+ }
246
+ // Fallback for non-browser environments
247
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
248
+ const r = (Math.random() * 16) | 0;
249
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
250
+ return v.toString(16);
251
+ });
252
+ }
253
+ /** Convert field options to SelectOption array for enums. */
254
+ function optionsToSelectOptions(values) {
255
+ // Handle both raw arrays and Enums() return objects
256
+ const arr = Array.isArray(values)
257
+ ? values
258
+ : values
259
+ .entries;
260
+ return arr.map((v, index) => {
261
+ if (typeof v === "string") {
262
+ return {
263
+ value: v,
264
+ label: toLabel(v),
265
+ position: index,
266
+ };
267
+ }
268
+ return {
269
+ value: v.value,
270
+ label: v.label ?? toLabel(v.value),
271
+ position: index,
272
+ };
273
+ });
274
+ }
275
+ /**
276
+ * Compile a `belongsToMany({ fields })` declaration into link-attribute
277
+ * definitions — the typed payload each link row may carry.
278
+ *
279
+ * A join's columns are declared with the same field builders as a collection's,
280
+ * but a link is an EDGE, not a record: only a small set of value kinds makes
281
+ * sense on one. Anything richer is asking for a real collection, so it maps to
282
+ * TEXT rather than silently pretending to be supported.
283
+ */
284
+ function compileLinkAttributes(fields) {
285
+ if (!fields)
286
+ return undefined;
287
+ const out = [];
288
+ for (const [name, builder] of Object.entries(fields)) {
289
+ const built = builder.build();
290
+ const type = built.type === "NUMBER" || built.type === "NUMERIC"
291
+ ? "NUMBER"
292
+ : built.type === "BOOLEAN"
293
+ ? "BOOLEAN"
294
+ : built.type === "SELECT"
295
+ ? "SELECT"
296
+ : "TEXT";
297
+ out.push({
298
+ name,
299
+ label: built.label || name,
300
+ type,
301
+ ...(type === "SELECT" && built.options
302
+ ? { options: built.options.map((o) => o.value) }
303
+ : {}),
304
+ ...(built.isRequired ? { isRequired: true } : {}),
305
+ ...(built.description ? { description: built.description } : {}),
306
+ });
307
+ }
308
+ return out.length > 0 ? out : undefined;
309
+ }
310
+ /** Build a complete FieldDefinition from builder state. */
311
+ function buildFieldDefinition(state) {
312
+ const universalIdentifier = generateUniversalIdentifier();
313
+ const recommendedFormElements = isFieldType(state.type)
314
+ ? getRecommendedFormElements(state.type)
315
+ : [];
316
+ const fieldDef = {
317
+ universalIdentifier,
318
+ name: "", // Will be set by collection()
319
+ label: "", // Will be set by collection()
320
+ type: state.type === "__relation__" ? "RELATION" : state.type,
321
+ ...(state.description !== undefined
322
+ ? { description: state.description }
323
+ : {}),
324
+ ...(state.required !== undefined ? { isRequired: state.required } : {}),
325
+ isNullable: state.nullable ?? !state.required,
326
+ ...(state.unique !== undefined ? { isUnique: state.unique } : {}),
327
+ defaultValue: state.defaultValue,
328
+ ...(state.indexed !== undefined ? { isIndexed: state.indexed } : {}),
329
+ ...(state.formElement !== undefined
330
+ ? { formElement: state.formElement }
331
+ : {}),
332
+ ...(state.elementMetadata !== undefined
333
+ ? { elementMetadata: state.elementMetadata }
334
+ : {}),
335
+ ...(recommendedFormElements.length > 0 ? { recommendedFormElements } : {}),
336
+ ...(state.validation !== undefined ? { validation: state.validation } : {}),
337
+ ...(state.helperText !== undefined ? { helperText: state.helperText } : {}),
338
+ ...(state.dynamicOptions !== undefined
339
+ ? { dynamicOptions: state.dynamicOptions }
340
+ : {}),
341
+ ...(state.options !== undefined ? { options: state.options } : {}),
342
+ ...(state.relationTarget !== undefined
343
+ ? { relationTarget: state.relationTarget }
344
+ : {}),
345
+ ...(state.relationCardinality !== undefined
346
+ ? { relationCardinality: state.relationCardinality }
347
+ : {}),
348
+ ...(state.linkAttributes !== undefined
349
+ ? { linkAttributes: state.linkAttributes }
350
+ : {}),
351
+ ...(state.onDelete !== undefined ? { onDelete: state.onDelete } : {}),
352
+ ...(state.strategy !== undefined ? { strategy: state.strategy } : {}),
353
+ ...(state.separateTableName !== undefined
354
+ ? { separateTableName: state.separateTableName }
355
+ : {}),
356
+ ...(state.encryption !== undefined && state.encryption !== "none"
357
+ ? { encryption: state.encryption }
358
+ : {}),
359
+ ...(state.isPrivate ? { isPrivate: true } : {}),
360
+ };
361
+ // Set position to a default (will be overridden by collection)
362
+ fieldDef.position = 0;
363
+ return fieldDef;
364
+ }
365
+ /** The fields builder namespace — exported for consumer use. */
366
+ export const fields = {
367
+ text(options) {
368
+ return createFieldBuilder({ type: "TEXT", ...options });
369
+ },
370
+ number(options) {
371
+ return createFieldBuilder({ type: "NUMBER", ...options });
372
+ },
373
+ numeric(options) {
374
+ return createFieldBuilder({ type: "NUMERIC", ...options });
375
+ },
376
+ boolean(options) {
377
+ return createFieldBuilder({ type: "BOOLEAN", ...options });
378
+ },
379
+ date(options) {
380
+ return createFieldBuilder({ type: "DATE", ...options });
381
+ },
382
+ dateTime(options) {
383
+ return createFieldBuilder({ type: "DATE_TIME", ...options });
384
+ },
385
+ uuid(options) {
386
+ return createFieldBuilder({ type: "UUID", ...options });
387
+ },
388
+ enum(values, options) {
389
+ return createFieldBuilder({
390
+ type: "SELECT",
391
+ options: optionsToSelectOptions(values),
392
+ ...options,
393
+ });
394
+ },
395
+ multiEnum(values, options) {
396
+ return createFieldBuilder({
397
+ type: "MULTI_SELECT",
398
+ options: optionsToSelectOptions(values),
399
+ ...options,
400
+ });
401
+ },
402
+ reference(targetCollection, options) {
403
+ return createFieldBuilder({
404
+ type: "RELATION",
405
+ relationTarget: targetCollection, // Will be resolved to universalIdentifier by collection()
406
+ onDelete: options?.onDelete ?? "restrict",
407
+ ...options,
408
+ });
409
+ },
410
+ relation() {
411
+ return createFieldBuilder({ type: "__relation__" });
412
+ },
413
+ url(options) {
414
+ return createFieldBuilder({ type: "TEXT", ...options });
415
+ },
416
+ email(options) {
417
+ return createFieldBuilder({ type: "EMAILS", ...options });
418
+ },
419
+ phone(options) {
420
+ return createFieldBuilder({ type: "PHONES", ...options });
421
+ },
422
+ richText(options) {
423
+ return createFieldBuilder({ type: "RICH_TEXT", ...options });
424
+ },
425
+ json(options) {
426
+ return createFieldBuilder({ type: "RAW_JSON", ...options });
427
+ },
428
+ array(options) {
429
+ return createFieldBuilder({ type: "ARRAY", ...options });
430
+ },
431
+ measurement(config, options) {
432
+ return createFieldBuilder({
433
+ type: "RAW_JSON",
434
+ formElement: "measurement",
435
+ elementMetadata: config,
436
+ ...options,
437
+ });
438
+ },
439
+ object(config, options) {
440
+ const props = "properties" in config ? config.properties : config;
441
+ const properties = Object.entries(props).map(([name, builder]) => ({
442
+ name,
443
+ field: builder.build(),
444
+ }));
445
+ return createFieldBuilder({
446
+ type: "RAW_JSON",
447
+ formElement: "object",
448
+ elementMetadata: { properties },
449
+ ...options,
450
+ });
451
+ },
452
+ file(config, options) {
453
+ return createFieldBuilder({
454
+ type: "FILES",
455
+ formElement: "formFileValue",
456
+ elementMetadata: config,
457
+ ...options,
458
+ });
459
+ },
460
+ address(config, options) {
461
+ return createFieldBuilder({
462
+ type: "RAW_JSON",
463
+ formElement: "address_group",
464
+ elementMetadata: config,
465
+ ...options,
466
+ });
467
+ },
468
+ geo(options) {
469
+ return createFieldBuilder({
470
+ type: "RAW_JSON",
471
+ formElement: "geo",
472
+ ...options,
473
+ });
474
+ },
475
+ };
476
+ /**
477
+ * Compact alias for `fields`. Same object, shorter name.
478
+ *
479
+ * ```ts
480
+ * import { bd } from "@businessdash/sdk";
481
+ *
482
+ * collection("features", {
483
+ * fields: {
484
+ * name: bd.text().required(),
485
+ * },
486
+ * });
487
+ * ```
488
+ */
489
+ export const bd = fields;
490
+ /** Convert a table name to singular/plural names and labels. */
491
+ function parseTableName(tableName) {
492
+ // For labels, keep underscores so toLabel can convert them properly
493
+ // For names, convert to camelCase (no underscores)
494
+ const singularForLabel = tableName.endsWith("ies")
495
+ ? `${tableName.slice(0, -3)}y`
496
+ : tableName.replace(/_?s$/, "");
497
+ const pluralForLabel = tableName;
498
+ const toCamelCase = (str) => str.replace(/[_-](\w)/g, (_, c) => c.toUpperCase());
499
+ return {
500
+ nameSingular: toCamelCase(singularForLabel),
501
+ namePlural: toCamelCase(pluralForLabel),
502
+ labelSingular: toLabel(singularForLabel),
503
+ labelPlural: toLabel(pluralForLabel),
504
+ labelIdentifierFieldName: toCamelCase(singularForLabel),
505
+ };
506
+ }
507
+ /**
508
+ * Define a collection (table) with a fluent fields API.
509
+ *
510
+ * @example
511
+ * ```ts
512
+ * const FeatureStatus = Enums(["planned", "inProgress", "completed"]);
513
+ *
514
+ * const featureGroups = collection("feature_groups", {
515
+ * fields: {
516
+ * name: fields.text().required(),
517
+ * category_id: fields.reference("categories").required(),
518
+ * status: fields.enum(FeatureStatus).default("planned"),
519
+ * icon: fields.text().optional(),
520
+ * image_url: fields.url().optional(),
521
+ * },
522
+ * });
523
+ * ```
524
+ */
525
+ export function collection(tableName, options) {
526
+ const { nameSingular, namePlural, labelSingular, labelPlural, labelIdentifierFieldName, } = parseTableName(tableName);
527
+ const universalIdentifier = options.universalIdentifier ??
528
+ generateUniversalIdentifier(`biab:collection:${nameSingular}`);
529
+ // Build fields with proper names and labels, collecting relation metadata
530
+ const fieldEntries = Object.entries(options.fields);
531
+ const relations = [];
532
+ let fieldIndex = 0;
533
+ const fields = [];
534
+ for (const [fieldName, builder] of fieldEntries) {
535
+ const relationMeta = getRelationMeta(builder);
536
+ if (relationMeta) {
537
+ relations.push({ fieldName, meta: relationMeta });
538
+ }
539
+ // A many-to-many USED TO be dropped here: the parent got no field at all,
540
+ // on the assumption that the link belonged to a separate join collection
541
+ // the author would build a second form for. That's the leak — it forced
542
+ // everyone to understand join tables to fill in a relationship.
543
+ //
544
+ // It now compiles to a normal RELATION field on the parent (cardinality
545
+ // "many"), so the generator gives it a live multi-picker and the link rows
546
+ // are written from the same form. The link storage is still the universal
547
+ // link table; there is no per-pair collection to name or maintain.
548
+ const fieldDef = builder.build();
549
+ fields.push({
550
+ ...fieldDef,
551
+ universalIdentifier: generateUniversalIdentifier(`biab:collection:${nameSingular}:field:${fieldName}`),
552
+ name: fieldName,
553
+ label: toLabel(fieldName),
554
+ position: fieldIndex,
555
+ });
556
+ fieldIndex++;
557
+ }
558
+ // Resolve reference targets (collection names -> universalIdentifiers)
559
+ // This would need a registry of all collections to work properly
560
+ // For now, we store the target collection name and let the consumer resolve it
561
+ return {
562
+ universalIdentifier,
563
+ nameSingular,
564
+ namePlural,
565
+ labelSingular,
566
+ labelPlural,
567
+ labelIdentifierFieldName: options.labelIdentifierFieldName ??
568
+ (options.fields["name"]
569
+ ? "name"
570
+ : (fields[0]?.name ?? labelIdentifierFieldName)),
571
+ ...(options.icon !== undefined ? { icon: options.icon } : {}),
572
+ ...(options.description !== undefined
573
+ ? { description: options.description }
574
+ : {}),
575
+ ...(options.visibility !== undefined
576
+ ? { visibility: options.visibility }
577
+ : {}),
578
+ ...(options.idType !== undefined ? { idType: options.idType } : {}),
579
+ kind: "custom",
580
+ ...(options.rlsPolicies !== undefined
581
+ ? { rlsPolicies: options.rlsPolicies }
582
+ : {}),
583
+ fields,
584
+ ...(relations.length > 0 ? { relations } : {}),
585
+ };
586
+ }
587
+ /**
588
+ * Polymorphic relations (one field pointing at several object kinds) aren't
589
+ * accepted yet. Declaring one fails fast here rather than at upload.
590
+ */
591
+ export const UNSUPPORTED_FIELD_TYPES = ["MORPH_RELATION"];
592
+ /** Relation values live in a link table, not in the record's own storage. */
593
+ export const RELATION_FIELD_TYPES = [
594
+ "RELATION",
595
+ "MORPH_RELATION",
596
+ ];
597
+ export function isRelationFieldType(type) {
598
+ return RELATION_FIELD_TYPES.includes(type);
599
+ }
600
+ const DELETE_ACTION_TO_ON_DELETE = {
601
+ CASCADE: "cascade",
602
+ RESTRICT: "restrict",
603
+ "SET NULL": "setNull",
604
+ "NO ACTION": undefined,
605
+ };
606
+ export const PREDEFINED_UNITS = {
607
+ sound: ["dB"],
608
+ pressure: ["Pa", "kPa", "bar", "psi", "atm", "mmHg"],
609
+ torque: ["in-lb", "N-m", "ft-lb", "kgf-m"],
610
+ speed: ["m/s", "km/h", "mph", "ft/s", "kn", "ly/yr", "c", "Mach"],
611
+ time: ["ms", "s", "min", "h", "day", "wk", "yr"],
612
+ area: ["m2", "km2", "ft2", "ac", "ha", "mi2"],
613
+ volume: ["mL", "L", "m3", "fl oz", "cup", "pt", "qt", "gal"],
614
+ power: ["W", "kW", "MW", "GW", "hp"],
615
+ energy: ["J", "kJ", "Wh", "kWh", "MWh", "cal", "kcal"],
616
+ storage: ["B", "KB", "MB", "GB", "TB", "PB", "KiB", "MiB", "GiB", "TiB"],
617
+ storageSpeed: ["MHz", "MT/s", "GB/s", "MB/s", "IOPS", "GT/s"],
618
+ frequency: ["Hz", "kHz", "MHz", "GHz", "THz"],
619
+ temperature: ["C", "F", "K"],
620
+ mass: ["mg", "g", "kg", "t", "oz", "lb", "st"],
621
+ length: ["nm", "µm", "mm", "cm", "m", "km", "in", "ft", "yd", "mi"],
622
+ currency: ["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR"],
623
+ };
624
+ /** Runtime field value validator using ValidationRules. */
625
+ export function validateFieldValue(value, rules, context) {
626
+ if (value === undefined || value === null || value === "") {
627
+ if (rules.notNull) {
628
+ return {
629
+ valid: false,
630
+ value,
631
+ error: "Value is required and cannot be null or empty.",
632
+ };
633
+ }
634
+ if (rules.requiredIf && context) {
635
+ const refValue = context[rules.requiredIf.fieldRef];
636
+ const conditionMet = rules.requiredIf.op === "equals"
637
+ ? rules.requiredIf.values.includes(refValue)
638
+ : !rules.requiredIf.values.includes(refValue);
639
+ if (conditionMet) {
640
+ return {
641
+ valid: false,
642
+ value,
643
+ error: `This field is required when "${rules.requiredIf.fieldRef}" ${rules.requiredIf.op === "equals" ? "equals" : "does not equal"} one of [${rules.requiredIf.values.join(", ")}].`,
644
+ };
645
+ }
646
+ }
647
+ return { valid: true, value: null };
648
+ }
649
+ // String / array length checks — also return early for plain strings/arrays
650
+ // when no date rule applies (date values are strings that need further checks).
651
+ if (typeof value === "string" || Array.isArray(value)) {
652
+ const len = value.length;
653
+ if (rules.minLength !== undefined && len < rules.minLength) {
654
+ return {
655
+ valid: false,
656
+ value,
657
+ error: `Must contain at least ${rules.minLength} entries or characters.`,
658
+ };
659
+ }
660
+ if (rules.maxLength !== undefined && len > rules.maxLength) {
661
+ return {
662
+ valid: false,
663
+ value,
664
+ error: `Must contain no more than ${rules.maxLength} entries or characters.`,
665
+ };
666
+ }
667
+ if (rules.regex && typeof value === "string") {
668
+ const pattern = typeof rules.regex === "string" ? new RegExp(rules.regex) : rules.regex;
669
+ if (!pattern.test(value)) {
670
+ return {
671
+ valid: false,
672
+ value,
673
+ error: `Value does not match the required pattern.`,
674
+ };
675
+ }
676
+ }
677
+ const hasDateRule = rules.lessThanToday || rules.greaterThanToday || rules.specificDateValue;
678
+ if (!hasDateRule) {
679
+ return { valid: true, value };
680
+ }
681
+ }
682
+ // Date checks — must run before numeric NaN short-circuit (date strings
683
+ // like "2000-01-01" are NaN when cast to Number but still need validation).
684
+ if (rules.lessThanToday ||
685
+ rules.greaterThanToday ||
686
+ rules.specificDateValue) {
687
+ if (typeof value !== "string" && typeof value !== "number") {
688
+ return { valid: false, value, error: "Value is not a valid date." };
689
+ }
690
+ const targetTime = new Date(value).getTime();
691
+ if (Number.isNaN(targetTime)) {
692
+ return { valid: false, value, error: "Value is not a valid date." };
693
+ }
694
+ const now = new Date();
695
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
696
+ if (rules.lessThanToday && targetTime >= todayStart) {
697
+ return {
698
+ valid: false,
699
+ value,
700
+ error: "Date must be historical (less than today).",
701
+ };
702
+ }
703
+ if (rules.greaterThanToday && targetTime <= todayStart) {
704
+ return {
705
+ valid: false,
706
+ value,
707
+ error: "Date must be a future target (greater than today).",
708
+ };
709
+ }
710
+ if (rules.specificDateValue === "today()" && targetTime !== todayStart) {
711
+ return {
712
+ valid: false,
713
+ value,
714
+ error: "Date value must map exactly to today.",
715
+ };
716
+ }
717
+ return { valid: true, value };
718
+ }
719
+ // Numeric checks
720
+ const numericValue = Number(value);
721
+ if (Number.isNaN(numericValue)) {
722
+ return { valid: true, value };
723
+ }
724
+ if (rules.positive && numericValue < 0) {
725
+ return {
726
+ valid: false,
727
+ value: numericValue,
728
+ error: "Value must be a positive number (greater than or equal to 0).",
729
+ };
730
+ }
731
+ if (rules.lessThan !== undefined && numericValue >= rules.lessThan) {
732
+ return {
733
+ valid: false,
734
+ value: numericValue,
735
+ error: `Value must be strictly less than ${rules.lessThan}.`,
736
+ };
737
+ }
738
+ if (rules.greaterThan !== undefined && numericValue <= rules.greaterThan) {
739
+ return {
740
+ valid: false,
741
+ value: numericValue,
742
+ error: `Value must be strictly greater than ${rules.greaterThan}.`,
743
+ };
744
+ }
745
+ if (rules.isDecimal && numericValue % 1 === 0) {
746
+ return {
747
+ valid: false,
748
+ value: numericValue,
749
+ error: "Value must contain an active decimal fractional component.",
750
+ };
751
+ }
752
+ // Transform: apply rounding or floor
753
+ let transformed = numericValue;
754
+ if (rules.round !== undefined) {
755
+ const factor = 10 ** rules.round;
756
+ transformed = Math.round(transformed * factor) / factor;
757
+ }
758
+ else if (rules.floor) {
759
+ transformed = Math.floor(transformed);
760
+ }
761
+ if (transformed !== numericValue) {
762
+ return { valid: true, value: transformed };
763
+ }
764
+ return { valid: true, value: numericValue };
765
+ }
766
+ /** Runtime measurement value validator. */
767
+ export function validateAndResolveMeasurement(input, meta) {
768
+ if (input.value === undefined || input.value === null) {
769
+ return { valid: true };
770
+ }
771
+ const numValue = Number(input.value);
772
+ if (Number.isNaN(numValue)) {
773
+ return { valid: false, error: "Measurement value must be a valid number." };
774
+ }
775
+ if (!meta.allow_decimals && numValue % 1 !== 0) {
776
+ return {
777
+ valid: false,
778
+ error: `Decimals are not allowed for measurement category "${meta.category}".`,
779
+ };
780
+ }
781
+ if (meta.unitPicker === "fixed" && meta.category in PREDEFINED_UNITS) {
782
+ const allowed = PREDEFINED_UNITS[meta.category];
783
+ if (allowed && !allowed.includes(input.unit)) {
784
+ return {
785
+ valid: false,
786
+ error: `Unit "${input.unit}" is not in the fixed set [${allowed.join(", ")}] for category "${meta.category}".`,
787
+ };
788
+ }
789
+ }
790
+ let finalValue = numValue;
791
+ if (meta.resolve_to_your_unit && input.unit !== meta.resolve_to_your_unit) {
792
+ finalValue = numValue;
793
+ }
794
+ return {
795
+ valid: true,
796
+ value: { value: finalValue, unit: meta.resolve_to_your_unit || input.unit },
797
+ };
798
+ }
799
+ /** Compatibility map: FieldType → valid form element names. */
800
+ const FIELD_FORM_ELEMENT_MAP = {
801
+ TEXT: [
802
+ "short_text",
803
+ "long_text",
804
+ "email",
805
+ "url",
806
+ "rich_text_input",
807
+ "dropdown",
808
+ "radio_group",
809
+ "choice_cards",
810
+ "services_selector",
811
+ "phone_number",
812
+ "date",
813
+ "credit_card",
814
+ "collection_select",
815
+ "timePicker",
816
+ "time_picker.date",
817
+ "time_picker.time",
818
+ "color",
819
+ ],
820
+ NUMBER: [
821
+ "number",
822
+ "currency",
823
+ "range",
824
+ "star_rating",
825
+ "happiness_rating",
826
+ "phone_number_basic",
827
+ ],
828
+ NUMERIC: [
829
+ "number",
830
+ "currency",
831
+ "range",
832
+ "star_rating",
833
+ "happiness_rating",
834
+ "phone_number_basic",
835
+ ],
836
+ BOOLEAN: ["yes_no_toggle", "yes_no_checkbox"],
837
+ DATE: ["date", "date_range"],
838
+ DATE_TIME: [
839
+ "timePicker",
840
+ "time_picker.date",
841
+ "time_picker.time",
842
+ "time_picker_timeslot",
843
+ "time_picker_datetime",
844
+ "date_range",
845
+ ],
846
+ UUID: ["short_text"],
847
+ SELECT: ["dropdown", "radio_group"],
848
+ MULTI_SELECT: ["multi-select", "checkbox_group", "tags"],
849
+ RELATION: ["collection_select"],
850
+ RICH_TEXT: ["rich_text_input"],
851
+ RAW_JSON: [
852
+ "address_group",
853
+ "measurement",
854
+ "availability",
855
+ "date_range",
856
+ "object",
857
+ "geo",
858
+ "time_picker_timeslot",
859
+ "time_picker_datetime",
860
+ ],
861
+ ARRAY: [
862
+ "multi-select",
863
+ "checkbox_group_many",
864
+ "choice_cards_many",
865
+ "services_selector_many",
866
+ "tags",
867
+ "formFileValue",
868
+ ],
869
+ EMAILS: ["email"],
870
+ PHONES: ["phone_number", "phone_number_basic"],
871
+ RATING: ["star_rating", "happiness_rating"],
872
+ CURRENCY: ["currency"],
873
+ FILES: ["formFileValue"],
874
+ };
875
+ function getRecommendedFormElements(fieldType) {
876
+ return FIELD_FORM_ELEMENT_MAP[fieldType] ?? [];
877
+ }
878
+ function isValidFormElement(fieldType, elementName) {
879
+ const allowed = FIELD_FORM_ELEMENT_MAP[fieldType];
880
+ if (!allowed)
881
+ return false;
882
+ return allowed.some((e) => e === elementName);
883
+ }
884
+ const NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
885
+ function assertSelectOptions(def) {
886
+ const isSelect = def.type === "SELECT" || def.type === "MULTI_SELECT";
887
+ if (!isSelect) {
888
+ if (def.options && def.options.length > 0) {
889
+ throw new Error(`defineField: "${def.name}" is a ${def.type} field but declares options — options belong to SELECT / MULTI_SELECT.`);
890
+ }
891
+ return;
892
+ }
893
+ if (!def.options || def.options.length === 0) {
894
+ throw new Error(`defineField: ${def.type} field "${def.name}" requires at least one option.`);
895
+ }
896
+ const values = new Set();
897
+ const identifiers = new Set();
898
+ for (const option of def.options) {
899
+ if (!option.value) {
900
+ throw new Error(`defineField: ${def.type} field "${def.name}" has an option with an empty value.`);
901
+ }
902
+ if (values.has(option.value)) {
903
+ throw new Error(`defineField: ${def.type} field "${def.name}" has duplicate option value "${option.value}".`);
904
+ }
905
+ values.add(option.value);
906
+ // Identity is optional, but a duplicated one is always a copy-paste
907
+ // bug — it would make two options indistinguishable to the diff.
908
+ if (option.universalIdentifier) {
909
+ if (identifiers.has(option.universalIdentifier)) {
910
+ throw new Error(`defineField: ${def.type} field "${def.name}" reuses option universalIdentifier "${option.universalIdentifier}" — each option needs its own.`);
911
+ }
912
+ identifiers.add(option.universalIdentifier);
913
+ }
914
+ }
915
+ }
916
+ function assertRelation(def) {
917
+ if (def.type === "RELATION") {
918
+ if (!def.relationTarget) {
919
+ throw new Error(`defineField: RELATION field "${def.name}" requires relationTarget (the universalIdentifier of the object it points at).`);
920
+ }
921
+ return;
922
+ }
923
+ if (def.relationTarget) {
924
+ throw new Error(`defineField: "${def.name}" is a ${def.type} field but declares relationTarget — that belongs to RELATION fields.`);
925
+ }
926
+ if (def.onDelete) {
927
+ throw new Error(`defineField: "${def.name}" is a ${def.type} field but declares onDelete — that belongs to RELATION fields.`);
928
+ }
929
+ }
930
+ function internalDefineField(definition) {
931
+ if (!definition.universalIdentifier) {
932
+ throw new Error("defineCollection: universalIdentifier is required.");
933
+ }
934
+ if (!definition.name || !NAME_PATTERN.test(definition.name)) {
935
+ throw new Error(`defineCollection: name "${definition.name}" must be camelCase, starting with a lowercase letter.`);
936
+ }
937
+ if (!definition.label) {
938
+ throw new Error(`defineCollection: "${definition.name}" requires a label.`);
939
+ }
940
+ if (!isFieldType(definition.type)) {
941
+ throw new Error(`defineCollection: unknown field type "${definition.type}" on "${definition.name}".`);
942
+ }
943
+ if (UNSUPPORTED_FIELD_TYPES.includes(definition.type)) {
944
+ throw new Error(`defineCollection: field type ${definition.type} isn't supported yet (on "${definition.name}").`);
945
+ }
946
+ assertSelectOptions(definition);
947
+ assertRelation(definition);
948
+ return Object.freeze(definition);
949
+ }
950
+ export const objectVisibilityValues = [
951
+ "private",
952
+ "authenticated",
953
+ "public",
954
+ ];
955
+ function internalDefineObject(definition) {
956
+ if (!definition.universalIdentifier) {
957
+ throw new Error("defineField: universalIdentifier is required.");
958
+ }
959
+ for (const key of ["nameSingular", "namePlural"]) {
960
+ const value = definition[key];
961
+ if (!value || !NAME_PATTERN.test(value)) {
962
+ throw new Error(`defineField: ${key} "${value}" must be camelCase, starting with a lowercase letter.`);
963
+ }
964
+ }
965
+ if (definition.nameSingular === definition.namePlural) {
966
+ throw new Error(`defineField: nameSingular and namePlural are both "${definition.nameSingular}" — they must differ, since routes and views derive from them.`);
967
+ }
968
+ if (!definition.labelSingular || !definition.labelPlural) {
969
+ throw new Error(`defineField: "${definition.nameSingular}" requires labelSingular and labelPlural.`);
970
+ }
971
+ if (definition.visibility !== undefined &&
972
+ !objectVisibilityValues.includes(definition.visibility)) {
973
+ throw new Error(`defineField: "${definition.nameSingular}" has visibility "${definition.visibility}" — must be one of ${objectVisibilityValues.join(", ")}.`);
974
+ }
975
+ const kind = definition.kind ?? "custom";
976
+ if (kind === "standard" && !definition.standardEntityName) {
977
+ throw new Error(`defineField: standard object "${definition.nameSingular}" requires standardEntityName.`);
978
+ }
979
+ if (kind === "custom" && definition.standardEntityName) {
980
+ throw new Error(`defineField: custom object "${definition.nameSingular}" must not set standardEntityName.`);
981
+ }
982
+ if (definition.fields.length === 0) {
983
+ throw new Error(`defineField: "${definition.nameSingular}" declares no fields.`);
984
+ }
985
+ const names = new Set();
986
+ const identifiers = new Set();
987
+ for (const field of definition.fields) {
988
+ if (names.has(field.name)) {
989
+ throw new Error(`defineField: duplicate field name "${field.name}" on "${definition.nameSingular}".`);
990
+ }
991
+ names.add(field.name);
992
+ if (identifiers.has(field.universalIdentifier)) {
993
+ throw new Error(`defineField: duplicate field universalIdentifier "${field.universalIdentifier}" on "${definition.nameSingular}".`);
994
+ }
995
+ identifiers.add(field.universalIdentifier);
996
+ }
997
+ if (definition.labelIdentifierFieldName &&
998
+ !names.has(definition.labelIdentifierFieldName)) {
999
+ throw new Error(`defineField: labelIdentifierFieldName "${definition.labelIdentifierFieldName}" isn't a field on "${definition.nameSingular}".`);
1000
+ }
1001
+ return Object.freeze({ ...definition, kind });
1002
+ }
1003
+ /**
1004
+ * The dispatch rule both `defineCollection` and `defineField` run on: an
1005
+ * OBJECT definition names itself and carries fields; a FIELD definition does
1006
+ * neither. Named once as a predicate so TypeScript narrows the union instead
1007
+ * of the implementations falling back to `any` — and so the two entry points
1008
+ * cannot drift apart on what "is an object" means.
1009
+ *
1010
+ * Stays defensive about non-objects: these are public entry points and
1011
+ * consumers pass what they pass.
1012
+ */
1013
+ function isObjectDefinitionInput(definition) {
1014
+ return (typeof definition === "object" &&
1015
+ definition !== null &&
1016
+ ("nameSingular" in definition || "fields" in definition));
1017
+ }
1018
+ export function defineCollection(definition) {
1019
+ if (isObjectDefinitionInput(definition)) {
1020
+ return internalDefineObject(definition);
1021
+ }
1022
+ return internalDefineField(definition);
1023
+ }
1024
+ export function defineField(definition) {
1025
+ if (isObjectDefinitionInput(definition)) {
1026
+ return internalDefineObject(definition);
1027
+ }
1028
+ console.warn("[@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.");
1029
+ return internalDefineField(definition);
1030
+ }
1031
+ /**
1032
+ * @deprecated Use `defineField` (or `defineCollection`) instead. `defineObject` will be removed in a future release.
1033
+ */
1034
+ export function defineObject(definition) {
1035
+ console.warn("[@businessdash/sdk] defineObject is deprecated. Please use defineField (or defineCollection) instead.");
1036
+ return internalDefineObject(definition);
1037
+ }
1038
+ /**
1039
+ * Sort keys recursively so two functionally identical models hash the same.
1040
+ * Must mirror the server's canonicalization byte-for-byte, or every push
1041
+ * fails the checksum check.
1042
+ *
1043
+ * Arrays keep their order on purpose: an option list's order is meaningful
1044
+ * (it's the render order), so `[draft, filed]` is NOT `[filed, draft]`.
1045
+ */
1046
+ function canonicalize(value) {
1047
+ if (value === null || typeof value !== "object")
1048
+ return value;
1049
+ if (Array.isArray(value))
1050
+ return value.map(canonicalize);
1051
+ const out = {};
1052
+ for (const k of Object.keys(value).sort()) {
1053
+ if (k === "__proto__" || k === "constructor" || k === "prototype")
1054
+ continue;
1055
+ out[k] = canonicalize(value[k]);
1056
+ }
1057
+ return out;
1058
+ }
1059
+ async function sha256Hex(input) {
1060
+ const buf = new TextEncoder().encode(input);
1061
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", buf);
1062
+ return Array.from(new Uint8Array(digest))
1063
+ .map((b) => b.toString(16).padStart(2, "0"))
1064
+ .join("");
1065
+ }
1066
+ export async function computeDataModelChecksum(body) {
1067
+ return sha256Hex(JSON.stringify(canonicalize(body)));
1068
+ }
1069
+ /**
1070
+ * Declare the org's custom database schema. Default-export the result from
1071
+ * `biab.data-model.config.ts`.
1072
+ *
1073
+ * ```ts
1074
+ * export default createSchema({
1075
+ * collections: [
1076
+ * defineField({
1077
+ * universalIdentifier: "6f1c…",
1078
+ * nameSingular: "permit", namePlural: "permits",
1079
+ * labelSingular: "Permit", labelPlural: "Permits",
1080
+ * labelIdentifierFieldName: "title",
1081
+ * fields: [
1082
+ * defineCollection({ universalIdentifier: "a2b4…", name: "title", label: "Title", type: "TEXT" }),
1083
+ * ],
1084
+ * }),
1085
+ * ],
1086
+ * })
1087
+ * ```
1088
+ */
1089
+ export function createSchema(input, options = {}) {
1090
+ const objects = input.collections ?? input.objects ?? [];
1091
+ const metadata = input.metadata ?? {};
1092
+ if (objects.length === 0) {
1093
+ throw new Error("createSchema: declare at least one collection.");
1094
+ }
1095
+ const globalIdType = options.defaultIdType ?? "uuid";
1096
+ // Auto-inject primary key id field for any mutable collection that doesn't define one.
1097
+ // Frozen objects (from the old defineField/defineCollection API) are skipped — they
1098
+ // explicitly declare all their fields.
1099
+ for (const object of objects) {
1100
+ if (Object.isFrozen(object))
1101
+ continue;
1102
+ const col = object;
1103
+ const hasId = col.fields.some((f) => f.name === "id") ||
1104
+ col.fields.some((f) => f.isPrimaryKey);
1105
+ if (hasId)
1106
+ continue;
1107
+ const idStrategy = col.idType ?? globalIdType;
1108
+ const idUid = generateUniversalIdentifier(`biab:collection:${object.nameSingular}:field:id`);
1109
+ col.fields.unshift({
1110
+ universalIdentifier: idUid,
1111
+ name: "id",
1112
+ label: "ID",
1113
+ type: idStrategy === "integer" ? "NUMBER" : "UUID",
1114
+ isPrimaryKey: true,
1115
+ isRequired: true,
1116
+ isNullable: false,
1117
+ isAutoIncrement: idStrategy === "integer",
1118
+ position: 0,
1119
+ });
1120
+ // Shift existing field positions
1121
+ for (let i = 1; i < col.fields.length; i++) {
1122
+ const f = col.fields[i];
1123
+ if (f)
1124
+ f.position = i;
1125
+ }
1126
+ }
1127
+ // Cross-object checks. defineObject can only see itself, so uniqueness
1128
+ // across the model and relation targets have to be settled here.
1129
+ const byIdentifier = new Map();
1130
+ const byName = new Map();
1131
+ const names = new Set();
1132
+ for (const object of objects) {
1133
+ if (byIdentifier.has(object.universalIdentifier)) {
1134
+ throw new Error(`defineDataModel: two objects share universalIdentifier "${object.universalIdentifier}" ("${object.nameSingular}" and "${byIdentifier.get(object.universalIdentifier)?.nameSingular}").`);
1135
+ }
1136
+ byIdentifier.set(object.universalIdentifier, object);
1137
+ byName.set(object.nameSingular, object);
1138
+ byName.set(object.namePlural, object);
1139
+ const seen = new Set();
1140
+ for (const name of [object.nameSingular, object.namePlural]) {
1141
+ if (seen.has(name))
1142
+ continue;
1143
+ seen.add(name);
1144
+ if (names.has(name)) {
1145
+ throw new Error(`defineDataModel: object name "${name}" is used twice — names must be unique across the model.`);
1146
+ }
1147
+ names.add(name);
1148
+ }
1149
+ }
1150
+ // Auto-generate junction collections for many-to-many relations
1151
+ const generatedJunctions = [];
1152
+ for (const object of objects) {
1153
+ const objectWithRelations = object;
1154
+ if (!objectWithRelations.relations)
1155
+ continue;
1156
+ for (const rel of objectWithRelations.relations) {
1157
+ if (rel.meta.type !== "many-to-many")
1158
+ continue;
1159
+ const targetName = rel.meta.targetCollection;
1160
+ const targetObj = byName.get(targetName);
1161
+ if (!targetObj) {
1162
+ throw new Error(`defineDataModel: "${object.nameSingular}.${rel.fieldName}" belongsToMany "${targetName}", which no object in this model declares.`);
1163
+ }
1164
+ const joinName = rel.meta.joinCollectionName ??
1165
+ `${object.nameSingular}_${targetObj.nameSingular}`;
1166
+ const { nameSingular: joinSingular, namePlural: joinPlural, labelSingular: joinLabelSingular, labelPlural: joinLabelPlural, } = parseTableName(joinName);
1167
+ // Build FK fields from junction to source (left side) and target (right side)
1168
+ const selfRelation = object.universalIdentifier === targetObj.universalIdentifier;
1169
+ const capitalizedSource = object.nameSingular.charAt(0).toUpperCase() +
1170
+ object.nameSingular.slice(1);
1171
+ const sourceFkName = selfRelation
1172
+ ? `source${capitalizedSource}Id`
1173
+ : `${object.nameSingular}Id`;
1174
+ const targetFkName = selfRelation
1175
+ ? `target${capitalizedSource}Id`
1176
+ : `${targetObj.nameSingular}Id`;
1177
+ const junctionUid = generateUniversalIdentifier(`biab:collection:${joinSingular}`);
1178
+ const sourceUid = generateUniversalIdentifier(`biab:collection:${joinSingular}:field:${sourceFkName}`);
1179
+ const targetUid = generateUniversalIdentifier(`biab:collection:${joinSingular}:field:${targetFkName}`);
1180
+ // Resolve cascade behavior: explicit onDeleteAction, then default to CASCADE
1181
+ const junctionOnDelete = rel.meta.onDeleteAction !== undefined
1182
+ ? (DELETE_ACTION_TO_ON_DELETE[rel.meta.onDeleteAction] ?? "cascade")
1183
+ : "cascade";
1184
+ // Determine id strategy for junction (use source collection's idType or global)
1185
+ const junctionIdType = object.idType ?? globalIdType;
1186
+ const junctionIdUid = generateUniversalIdentifier(`biab:collection:${joinSingular}:field:id`);
1187
+ const junctionFields = [
1188
+ {
1189
+ universalIdentifier: junctionIdUid,
1190
+ name: "id",
1191
+ label: "ID",
1192
+ type: junctionIdType === "integer" ? "NUMBER" : "UUID",
1193
+ isPrimaryKey: true,
1194
+ isRequired: true,
1195
+ isNullable: false,
1196
+ isAutoIncrement: junctionIdType === "integer",
1197
+ position: 0,
1198
+ },
1199
+ {
1200
+ universalIdentifier: sourceUid,
1201
+ name: sourceFkName,
1202
+ label: toLabel(sourceFkName),
1203
+ type: "RELATION",
1204
+ relationTarget: object.universalIdentifier,
1205
+ onDelete: junctionOnDelete,
1206
+ isNullable: false,
1207
+ recommendedFormElements: ["collection_select"],
1208
+ position: 1,
1209
+ },
1210
+ {
1211
+ universalIdentifier: targetUid,
1212
+ name: targetFkName,
1213
+ label: toLabel(targetFkName),
1214
+ type: "RELATION",
1215
+ relationTarget: targetObj.universalIdentifier,
1216
+ onDelete: junctionOnDelete,
1217
+ isNullable: false,
1218
+ recommendedFormElements: ["collection_select"],
1219
+ position: 2,
1220
+ },
1221
+ ];
1222
+ // Add any additional fields
1223
+ if (rel.meta.fields) {
1224
+ let pos = 3;
1225
+ for (const [addName, addBuilder] of Object.entries(rel.meta.fields)) {
1226
+ const addDef = addBuilder.build();
1227
+ junctionFields.push({
1228
+ ...addDef,
1229
+ universalIdentifier: generateUniversalIdentifier(`biab:collection:${joinSingular}:field:${addName}`),
1230
+ name: addName,
1231
+ label: toLabel(addName),
1232
+ position: pos,
1233
+ });
1234
+ pos++;
1235
+ }
1236
+ }
1237
+ const junctionObj = {
1238
+ universalIdentifier: junctionUid,
1239
+ kind: "custom",
1240
+ nameSingular: joinSingular,
1241
+ namePlural: joinPlural,
1242
+ labelSingular: joinLabelSingular,
1243
+ labelPlural: joinLabelPlural,
1244
+ labelIdentifierFieldName: sourceFkName,
1245
+ fields: junctionFields,
1246
+ };
1247
+ generatedJunctions.push(junctionObj);
1248
+ byIdentifier.set(junctionUid, junctionObj);
1249
+ byName.set(joinSingular, junctionObj);
1250
+ byName.set(joinPlural, junctionObj);
1251
+ names.add(joinSingular);
1252
+ names.add(joinPlural);
1253
+ }
1254
+ }
1255
+ // Append generated junction collections to the objects list
1256
+ objects.push(...generatedJunctions);
1257
+ // Resolve relation targets by collection name (allowing name-based references)
1258
+ for (const object of objects) {
1259
+ for (const field of object.fields) {
1260
+ if (field.type !== "RELATION" || !field.relationTarget)
1261
+ continue;
1262
+ if (!byIdentifier.has(field.relationTarget) &&
1263
+ byName.has(field.relationTarget)) {
1264
+ field.relationTarget = byName.get(field.relationTarget).universalIdentifier;
1265
+ }
1266
+ }
1267
+ }
1268
+ // A relation pointing at an object that isn't in the model can never be
1269
+ // resolved. Catch it here rather than as a server-side NOT_FOUND.
1270
+ for (const object of objects) {
1271
+ for (const field of object.fields) {
1272
+ if (field.type !== "RELATION" || !field.relationTarget)
1273
+ continue;
1274
+ if (!byIdentifier.has(field.relationTarget)) {
1275
+ throw new Error(`defineDataModel: "${object.nameSingular}.${field.name}" points at "${field.relationTarget}", which no object in this model declares.`);
1276
+ }
1277
+ }
1278
+ }
1279
+ // Validate form element compatibility
1280
+ for (const object of objects) {
1281
+ if (Object.isFrozen(object))
1282
+ continue;
1283
+ for (const field of object.fields) {
1284
+ if (!field.formElement)
1285
+ continue;
1286
+ const isValid = isValidFormElement(field.type, field.formElement);
1287
+ if (!isValid) {
1288
+ const recommended = getRecommendedFormElements(field.type);
1289
+ console.warn(`[BusinessDash SDK] Form element mismatch in "${object.nameSingular}.${field.name}": ` +
1290
+ `"${field.formElement}" does not fulfill field type "${field.type}". ` +
1291
+ (recommended.length > 0
1292
+ ? `Recommended: [${recommended.join(", ")}]`
1293
+ : "No compatible elements for this field type."));
1294
+ }
1295
+ }
1296
+ }
1297
+ // Compile-time validation rule checks
1298
+ for (const object of objects) {
1299
+ if (Object.isFrozen(object))
1300
+ continue;
1301
+ for (const field of object.fields) {
1302
+ const rules = field.validation;
1303
+ if (!rules)
1304
+ continue;
1305
+ const name = `"${object.nameSingular}.${field.name}"`;
1306
+ // Numerical type gating
1307
+ const isNumeric = field.type === "NUMBER" || field.type === "NUMERIC";
1308
+ const isTextOrArray = field.type === "TEXT" || field.type === "ARRAY";
1309
+ // minLength / maxLength only on TEXT or ARRAY
1310
+ if ((rules.minLength !== undefined || rules.maxLength !== undefined) &&
1311
+ !isTextOrArray) {
1312
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "minLength" and "maxLength" are only applicable to TEXT or ARRAY, not "${field.type}".`);
1313
+ }
1314
+ if ((rules.minLength !== undefined && rules.minLength < 0) ||
1315
+ (rules.maxLength !== undefined && rules.maxLength < 0)) {
1316
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "minLength" and "maxLength" must be positive integers.`);
1317
+ }
1318
+ if (rules.minLength !== undefined &&
1319
+ rules.maxLength !== undefined &&
1320
+ rules.minLength > rules.maxLength) {
1321
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "minLength" (${rules.minLength}) cannot exceed "maxLength" (${rules.maxLength}).`);
1322
+ }
1323
+ // Numeric boundary checks
1324
+ if (rules.lessThan !== undefined &&
1325
+ rules.greaterThan !== undefined &&
1326
+ rules.greaterThan >= rules.lessThan) {
1327
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "greaterThan" (${rules.greaterThan}) must be strictly less than "lessThan" (${rules.lessThan}).`);
1328
+ }
1329
+ // Math rules gated to numeric fields
1330
+ if ((rules.floor ||
1331
+ rules.isDecimal ||
1332
+ rules.round !== undefined ||
1333
+ rules.positive) &&
1334
+ !isNumeric) {
1335
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "floor", "isDecimal", "round", and "positive" are only applicable to NUMBER/NUMERIC, not "${field.type}".`);
1336
+ }
1337
+ // round must be non-negative integer
1338
+ if (rules.round !== undefined &&
1339
+ (rules.round < 0 || !Number.isInteger(rules.round))) {
1340
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "round" must be a non-negative whole integer (0, 1, 2, ...).`);
1341
+ }
1342
+ // floor and round conflict
1343
+ if (rules.floor && rules.round !== undefined) {
1344
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "floor" and "round" cannot be applied together.`);
1345
+ }
1346
+ // floor/round with isDecimal conflict
1347
+ if ((rules.floor || rules.round !== undefined) && rules.isDecimal) {
1348
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: "floor" or "round" returns whole integers, neutralizing "isDecimal".`);
1349
+ }
1350
+ // Date conflict checks
1351
+ if (rules.lessThanToday && rules.greaterThanToday) {
1352
+ throw new Error(`[BusinessDash SDK] Validation error on ${name}: cannot be both "lessThanToday" and "greaterThanToday".`);
1353
+ }
1354
+ }
1355
+ }
1356
+ let cached = null;
1357
+ function build() {
1358
+ if (cached)
1359
+ return cached;
1360
+ const out = {
1361
+ $schema: "https://biab.app/schemas/data-model/v1",
1362
+ version: 1,
1363
+ metadata: { ...metadata },
1364
+ objects: objects.map((object) => ({
1365
+ universalIdentifier: object.universalIdentifier,
1366
+ kind: object.kind ?? "custom",
1367
+ nameSingular: object.nameSingular,
1368
+ namePlural: object.namePlural,
1369
+ labelSingular: object.labelSingular,
1370
+ labelPlural: object.labelPlural,
1371
+ // ⚠️ Omitted when private — including when someone wrote
1372
+ // `visibility: 'private'` by hand. Absent and private are the same
1373
+ // model, so they must produce the same bytes.
1374
+ //
1375
+ // This is a checksum constraint, not a style choice. The CLI hashes
1376
+ // the artifact and the server recomputes it against a pinned golden
1377
+ // digest. Materializing this key would put `visibility` into EVERY
1378
+ // object of EVERY existing config and change every checksum that has
1379
+ // ever been computed — for a field nobody set.
1380
+ ...(object.visibility !== undefined && object.visibility !== "private"
1381
+ ? { visibility: object.visibility }
1382
+ : {}),
1383
+ ...(object.icon !== undefined ? { icon: object.icon } : {}),
1384
+ ...(object.description !== undefined
1385
+ ? { description: object.description }
1386
+ : {}),
1387
+ ...(object.labelIdentifierFieldName !== undefined
1388
+ ? { labelIdentifierFieldName: object.labelIdentifierFieldName }
1389
+ : {}),
1390
+ ...(object.standardEntityName !== undefined
1391
+ ? { standardEntityName: object.standardEntityName }
1392
+ : {}),
1393
+ fields: object.fields.map((field) => ({
1394
+ universalIdentifier: field.universalIdentifier,
1395
+ name: field.name,
1396
+ label: field.label,
1397
+ type: field.type,
1398
+ ...(field.icon !== undefined ? { icon: field.icon } : {}),
1399
+ ...(field.description !== undefined
1400
+ ? { description: field.description }
1401
+ : {}),
1402
+ ...(field.isNullable !== undefined
1403
+ ? { isNullable: field.isNullable }
1404
+ : {}),
1405
+ ...(field.isUnique !== undefined ? { isUnique: field.isUnique } : {}),
1406
+ ...(field.isIndexed !== undefined
1407
+ ? { isIndexed: field.isIndexed }
1408
+ : {}),
1409
+ ...(field.isPrimaryKey !== undefined
1410
+ ? { isPrimaryKey: field.isPrimaryKey }
1411
+ : {}),
1412
+ ...(field.isAutoIncrement !== undefined
1413
+ ? { isAutoIncrement: field.isAutoIncrement }
1414
+ : {}),
1415
+ ...(field.defaultValue !== undefined
1416
+ ? { defaultValue: field.defaultValue }
1417
+ : {}),
1418
+ ...(field.position !== undefined ? { position: field.position } : {}),
1419
+ ...(field.options !== undefined ? { options: field.options } : {}),
1420
+ ...(field.relationTarget !== undefined
1421
+ ? { relationTarget: field.relationTarget }
1422
+ : {}),
1423
+ ...(field.onDelete !== undefined ? { onDelete: field.onDelete } : {}),
1424
+ ...(field.strategy !== undefined ? { strategy: field.strategy } : {}),
1425
+ ...(field.separateTableName !== undefined
1426
+ ? { separateTableName: field.separateTableName }
1427
+ : {}),
1428
+ ...(field.formElement !== undefined
1429
+ ? { formElement: field.formElement }
1430
+ : {}),
1431
+ ...(field.elementMetadata !== undefined
1432
+ ? { elementMetadata: field.elementMetadata }
1433
+ : {}),
1434
+ ...(field.recommendedFormElements !== undefined
1435
+ ? { recommendedFormElements: field.recommendedFormElements }
1436
+ : {}),
1437
+ ...(field.validation !== undefined
1438
+ ? { validation: field.validation }
1439
+ : {}),
1440
+ ...(field.helperText !== undefined
1441
+ ? { helperText: field.helperText }
1442
+ : {}),
1443
+ ...(field.dynamicOptions !== undefined
1444
+ ? { dynamicOptions: field.dynamicOptions }
1445
+ : {}),
1446
+ })),
1447
+ })),
1448
+ };
1449
+ cached = out;
1450
+ return out;
1451
+ }
1452
+ return {
1453
+ objects: objects.map((o) => Object.freeze(o)),
1454
+ toJSONSchema: build,
1455
+ async checksum() {
1456
+ return computeDataModelChecksum(build());
1457
+ },
1458
+ };
1459
+ }
1460
+ /**
1461
+ * @deprecated Use `createSchema` instead. `defineDataModel` will be removed in a future release.
1462
+ */
1463
+ export function defineDataModel(input) {
1464
+ console.warn("[@businessdash/sdk] defineDataModel is deprecated. Please use createSchema instead.");
1465
+ return createSchema(input);
1466
+ }
1467
+ /**
1468
+ * Group collections (and nested boundaries) under a named feature.
1469
+ * The orchestrator flattens these when building the schema graph.
1470
+ *
1471
+ * ```ts
1472
+ * SchemaBoundary(
1473
+ * { featureId: "crm", name: "CRM", description: "Contact management" },
1474
+ * collection("contacts", { fields: { name: fields.text() } }),
1475
+ * )
1476
+ * ```
1477
+ */
1478
+ export function SchemaBoundary(meta, ...children) {
1479
+ return { type: "schema_boundary", meta, children };
1480
+ }
1481
+ /**
1482
+ * @deprecated Use `layoutContainers` instead. This API will be removed in a future release.
1483
+ */
1484
+ export const layout = {
1485
+ step: (name, children, options) => ({
1486
+ type: "step_group",
1487
+ name,
1488
+ ...(options?.helperText !== undefined
1489
+ ? { helperText: options.helperText }
1490
+ : {}),
1491
+ children,
1492
+ }),
1493
+ collection: (collectionId, options) => ({
1494
+ type: "render_collection",
1495
+ collectionId,
1496
+ ...(options?.helperText !== undefined
1497
+ ? { helperText: options.helperText }
1498
+ : {}),
1499
+ }),
1500
+ conditional: (fieldReference, condition, values, rules, children, options) => ({
1501
+ type: "conditional_group",
1502
+ fieldReference,
1503
+ condition,
1504
+ values,
1505
+ rules,
1506
+ ...(options?.helperText !== undefined
1507
+ ? { helperText: options.helperText }
1508
+ : {}),
1509
+ children,
1510
+ }),
1511
+ or: (options, containerOptions) => ({
1512
+ type: "or_group",
1513
+ options,
1514
+ ...(containerOptions?.helperText !== undefined
1515
+ ? { helperText: containerOptions.helperText }
1516
+ : {}),
1517
+ ...(containerOptions?.button_label !== undefined
1518
+ ? { button_label: containerOptions.button_label }
1519
+ : {}),
1520
+ }),
1521
+ multiPopulator: (collectionId, options) => ({
1522
+ type: "multi_populator",
1523
+ collectionId,
1524
+ ...(options?.max_entries !== undefined
1525
+ ? { max_entries: options.max_entries }
1526
+ : {}),
1527
+ ...(options?.button_label !== undefined
1528
+ ? { button_label: options.button_label }
1529
+ : {}),
1530
+ ...(options?.helperText !== undefined
1531
+ ? { helperText: options.helperText }
1532
+ : {}),
1533
+ }),
1534
+ };
1535
+ function createFormStepNode(name, options = {}, ...children) {
1536
+ return {
1537
+ type: "form_step",
1538
+ name,
1539
+ ...(options.showTitle !== undefined
1540
+ ? { showTitle: options.showTitle }
1541
+ : {}),
1542
+ ...(options.helperText !== undefined
1543
+ ? { helperText: options.helperText }
1544
+ : {}),
1545
+ children: children,
1546
+ };
1547
+ }
1548
+ function createConditionalBlockNode(config, ...children) {
1549
+ return {
1550
+ type: "conditional_block",
1551
+ matchStrategy: config.matchStrategy,
1552
+ rules: config.rules,
1553
+ ...(config.showTitle !== undefined ? { showTitle: config.showTitle } : {}),
1554
+ ...(config.helperText !== undefined
1555
+ ? { helperText: config.helperText }
1556
+ : {}),
1557
+ children: children,
1558
+ };
1559
+ }
1560
+ function createMultiPopulatorNode(config = {}, ...children) {
1561
+ const entryFields = config.entryFields
1562
+ ? Object.entries(config.entryFields).map(([name, builder], position) => {
1563
+ const relationMeta = getRelationMeta(builder);
1564
+ if (relationMeta?.type === "many-to-many") {
1565
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB entry field "${name}" cannot use belongsToMany(). Use a secondary-table output for relational entries.`);
1566
+ }
1567
+ return {
1568
+ ...builder.build(),
1569
+ name,
1570
+ label: toLabel(name),
1571
+ position,
1572
+ };
1573
+ })
1574
+ : undefined;
1575
+ return {
1576
+ type: "multi_populator",
1577
+ ...(config.max_entries !== undefined
1578
+ ? { max_entries: config.max_entries }
1579
+ : {}),
1580
+ ...(config.button_label !== undefined
1581
+ ? { button_label: config.button_label }
1582
+ : {}),
1583
+ ...(config.showTitle !== undefined ? { showTitle: config.showTitle } : {}),
1584
+ ...(config.helperText !== undefined
1585
+ ? { helperText: config.helperText }
1586
+ : {}),
1587
+ ...(config.output !== undefined ? { output: config.output } : {}),
1588
+ ...(entryFields !== undefined ? { entryFields } : {}),
1589
+ children: children,
1590
+ };
1591
+ }
1592
+ function createOrConditionNode(options = {}, branches = []) {
1593
+ return {
1594
+ type: "or_condition",
1595
+ ...(options.showTitle !== undefined
1596
+ ? { showTitle: options.showTitle }
1597
+ : {}),
1598
+ ...(options.helperText !== undefined
1599
+ ? { helperText: options.helperText }
1600
+ : {}),
1601
+ ...(options.button_label !== undefined
1602
+ ? { button_label: options.button_label }
1603
+ : {}),
1604
+ branches,
1605
+ };
1606
+ }
1607
+ export const layoutContainers = {
1608
+ FormStep: createFormStepNode,
1609
+ ConditionalBlock: createConditionalBlockNode,
1610
+ MultiPopulator: createMultiPopulatorNode,
1611
+ OrCondition: createOrConditionNode,
1612
+ };
1613
+ // ── createFormSchemaOrchestrator (0.9.50+) ────────────────────────────
1614
+ function toSlug(value) {
1615
+ return value
1616
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
1617
+ .replace(/[^a-zA-Z0-9]+/g, "-")
1618
+ .replace(/^-+|-+$/g, "")
1619
+ .toLowerCase();
1620
+ }
1621
+ /**
1622
+ * Schema declarations use descriptive SDK element names; persisted custom
1623
+ * forms use the renderer's compact names. Keep the translation at the
1624
+ * artifact boundary so declarations and form storage do not leak into one
1625
+ * another.
1626
+ */
1627
+ function toPersistedFormElement(element) {
1628
+ const aliases = {
1629
+ short_text: "text",
1630
+ long_text: "textarea",
1631
+ dropdown: "select",
1632
+ radio_group: "radio",
1633
+ yes_no_toggle: "boolean",
1634
+ yes_no_checkbox: "boolean_checkbox",
1635
+ "multi-select": "multi_select",
1636
+ checkbox_group: "checkbox",
1637
+ checkbox_group_many: "checkbox",
1638
+ choice_cards_many: "choice_cards",
1639
+ services_selector: "service_selector",
1640
+ services_selector_many: "service_selector",
1641
+ phone_number: "phone",
1642
+ phone_number_basic: "phone",
1643
+ timePicker: "time_picker",
1644
+ "time_picker.date": "date",
1645
+ "time_picker.time": "time_picker",
1646
+ time_picker_timeslot: "time_picker",
1647
+ time_picker_datetime: "time_picker",
1648
+ address_group: "address",
1649
+ range: "slider",
1650
+ formFileValue: "file_upload",
1651
+ };
1652
+ return aliases[element] ?? element;
1653
+ }
1654
+ function generateCollectionFormCandidates(objects, options) {
1655
+ // Indexed by BOTH universalIdentifier and nameSingular: `relationTarget` is
1656
+ // written by the author as a collection name and is only resolved to a uid
1657
+ // later in the pipeline. Looking up by uid alone silently found nothing, so a
1658
+ // relation rendered as a picker with no collection wired to it.
1659
+ const objectByIdentifier = new Map();
1660
+ for (const object of objects) {
1661
+ objectByIdentifier.set(object.universalIdentifier, object);
1662
+ objectByIdentifier.set(object.nameSingular, object);
1663
+ }
1664
+ return objects.flatMap((object) => {
1665
+ const fieldMap = {};
1666
+ const formFields = [];
1667
+ for (const field of object.fields) {
1668
+ if (field.isPrimaryKey || field.isAutoIncrement || field.isPrivate)
1669
+ continue;
1670
+ const declaredElement = field.formElement ?? field.recommendedFormElements?.[0];
1671
+ if (!declaredElement)
1672
+ continue;
1673
+ const type = toPersistedFormElement(declaredElement);
1674
+ const id = generateUniversalIdentifier(`biab:generated-form:${object.universalIdentifier}:field:${field.universalIdentifier}`);
1675
+ fieldMap[field.name] = id;
1676
+ const relationTarget = field.relationTarget
1677
+ ? objectByIdentifier.get(field.relationTarget)
1678
+ : undefined;
1679
+ const regex = field.validation?.regex;
1680
+ const min = field.validation?.minLength ??
1681
+ (field.validation?.greaterThan !== undefined
1682
+ ? field.validation.greaterThan
1683
+ : undefined);
1684
+ const max = field.validation?.maxLength ??
1685
+ (field.validation?.lessThan !== undefined
1686
+ ? field.validation.lessThan
1687
+ : undefined);
1688
+ formFields.push({
1689
+ id,
1690
+ label: field.label,
1691
+ type,
1692
+ required: field.isRequired === true || field.isNullable === false,
1693
+ outputKey: field.name,
1694
+ ...(field.options
1695
+ ? { options: field.options.map((option) => option.value) }
1696
+ : {}),
1697
+ ...(typeof field.defaultValue === "string" ||
1698
+ typeof field.defaultValue === "boolean"
1699
+ ? { defaultValue: field.defaultValue }
1700
+ : {}),
1701
+ ...(field.helperText ? { helpText: field.helperText } : {}),
1702
+ ...(regex
1703
+ ? {
1704
+ regexPattern: typeof regex === "string" ? regex : regex.source,
1705
+ }
1706
+ : {}),
1707
+ ...(min !== undefined || max !== undefined
1708
+ ? {
1709
+ validation: {
1710
+ ...(min !== undefined ? { min } : {}),
1711
+ ...(max !== undefined ? { max } : {}),
1712
+ },
1713
+ }
1714
+ : {}),
1715
+ ...(type === "collection_select" && relationTarget
1716
+ ? {
1717
+ collectionSelectConfig: {
1718
+ collectionKey: relationTarget.universalIdentifier,
1719
+ ...(relationTarget.labelIdentifierFieldName
1720
+ ? { labelField: relationTarget.labelIdentifierFieldName }
1721
+ : {}),
1722
+ valueMode: "id",
1723
+ // The relation's cardinality decides the picker, so an author
1724
+ // never configures "multi" — declaring that a record can
1725
+ // reference several of something IS the declaration that the
1726
+ // picker takes several. The join table stays invisible.
1727
+ ...(field.relationCardinality === "many"
1728
+ ? { multi: true }
1729
+ : {}),
1730
+ },
1731
+ }
1732
+ : {}),
1733
+ });
1734
+ }
1735
+ if (formFields.length === 0)
1736
+ return [];
1737
+ const sourceKey = `data-model:${object.universalIdentifier}`;
1738
+ return [
1739
+ {
1740
+ sourceKey,
1741
+ name: `${object.labelSingular} Form`,
1742
+ slug: `${toSlug(object.nameSingular)}-form`,
1743
+ description: object.description ??
1744
+ `Create one ${object.labelSingular.toLowerCase()} record.`,
1745
+ fields: formFields,
1746
+ settings: {
1747
+ isConsecutive: options.consecutive ?? false,
1748
+ showProgress: options.progress ?? options.consecutive ?? false,
1749
+ ...(options.progress_style
1750
+ ? {
1751
+ progressStyle: options.progress_style === "progress_bar"
1752
+ ? "bar"
1753
+ : "steps",
1754
+ }
1755
+ : {}),
1756
+ generatedFrom: {
1757
+ kind: "data_model",
1758
+ sourceKey,
1759
+ objectIdentifier: object.universalIdentifier,
1760
+ objectIdentifiers: [object.universalIdentifier],
1761
+ },
1762
+ actions: [
1763
+ {
1764
+ id: generateUniversalIdentifier(`biab:generated-form:${object.universalIdentifier}:action:create-record`),
1765
+ type: "create_records",
1766
+ enabled: true,
1767
+ mapping: {},
1768
+ config: {
1769
+ composite: {
1770
+ creates: [
1771
+ {
1772
+ alias: object.nameSingular,
1773
+ object: object.universalIdentifier,
1774
+ fieldMap,
1775
+ },
1776
+ ],
1777
+ },
1778
+ },
1779
+ },
1780
+ ],
1781
+ },
1782
+ },
1783
+ ];
1784
+ });
1785
+ }
1786
+ /** A record picker for one collection — "which row are we acting on?". */
1787
+ function recordIdentityField(object, purpose) {
1788
+ return {
1789
+ id: generateUniversalIdentifier(`biab:generated-form:${object.universalIdentifier}:${purpose}:record`),
1790
+ label: object.labelSingular,
1791
+ type: "collection_select",
1792
+ required: true,
1793
+ outputKey: "recordId",
1794
+ helpText: `Which ${object.labelSingular.toLowerCase()} to ${purpose}.`,
1795
+ collectionSelectConfig: {
1796
+ collectionKey: object.universalIdentifier,
1797
+ ...(object.labelIdentifierFieldName
1798
+ ? { labelField: object.labelIdentifierFieldName }
1799
+ : {}),
1800
+ valueMode: "id",
1801
+ },
1802
+ };
1803
+ }
1804
+ /**
1805
+ * What deleting one record of this collection would take with it.
1806
+ *
1807
+ * Read from the relations that actually point AT this collection and their
1808
+ * declared `onDelete`, rather than asserting a generic "respects referential
1809
+ * integrity" — which would be a promise the model may not keep.
1810
+ */
1811
+ function describeDeleteImpact(object, related) {
1812
+ const cascades = [];
1813
+ const blocks = [];
1814
+ // `relationTarget` is written by the author as a collection NAME and only
1815
+ // resolved to a universalIdentifier later in the pipeline, so match on either
1816
+ // — otherwise this silently finds nothing and every delete looks consequence-free.
1817
+ const identifies = (target) => target === object.universalIdentifier || target === object.nameSingular;
1818
+ for (const other of related) {
1819
+ if (other.universalIdentifier === object.universalIdentifier)
1820
+ continue;
1821
+ for (const field of other.fields) {
1822
+ if (!identifies(field.relationTarget))
1823
+ continue;
1824
+ if (field.onDelete === "cascade")
1825
+ cascades.push(other.labelPlural);
1826
+ else if ((field.onDelete ?? "restrict") === "restrict") {
1827
+ blocks.push(other.labelPlural);
1828
+ }
1829
+ }
1830
+ }
1831
+ const parts = [
1832
+ `Permanently remove this ${object.labelSingular.toLowerCase()}.`,
1833
+ ];
1834
+ if (cascades.length > 0) {
1835
+ parts.push(`Linked ${[...new Set(cascades)].join(", ")} will be deleted with it.`);
1836
+ }
1837
+ if (blocks.length > 0) {
1838
+ parts.push(`It can't be deleted while ${[...new Set(blocks)].join(", ")} still reference it.`);
1839
+ }
1840
+ return parts.join(" ");
1841
+ }
1842
+ /**
1843
+ * Generate the three atomic, single-record forms for one collection — Create,
1844
+ * Edit and Delete — instead of one big multi-step wizard.
1845
+ *
1846
+ * Relation fields become live collection pickers automatically, single or
1847
+ * multi according to the relation's own cardinality, so a many-to-many is
1848
+ * filled in inline and the join table never surfaces as a concept.
1849
+ *
1850
+ * Lineage: `create` deliberately KEEPS the base `data-model:<uid>` sourceKey
1851
+ * that the one-form-per-collection generator has always used, so promoting a
1852
+ * model that previously produced a single form updates that draft rather than
1853
+ * orphaning it and creating a duplicate. `edit` and `delete` take suffixed keys
1854
+ * off the same stem.
1855
+ */
1856
+ export function createSingleResourceForm(object, options = {}) {
1857
+ const { relatedObjects, ...orchestration } = options;
1858
+ // Reuse the existing per-collection generator rather than re-deriving field
1859
+ // mapping, element choice and picker config — one place decides how a
1860
+ // collection becomes a form.
1861
+ //
1862
+ // Siblings are passed in so relation targets RESOLVE: a picker needs its
1863
+ // target's identifier and label field, and without them in the map the field
1864
+ // renders as a picker wired to nothing. `.filter` keeps the subject first and
1865
+ // prevents a duplicate candidate for it.
1866
+ const generatorInput = [
1867
+ object,
1868
+ ...(relatedObjects ?? []).filter((o) => o.universalIdentifier !== object.universalIdentifier),
1869
+ ];
1870
+ const generated = generateCollectionFormCandidates(generatorInput, orchestration);
1871
+ const base = generated.find((c) => c.settings.generatedFrom.objectIdentifier === object.universalIdentifier);
1872
+ if (!base) {
1873
+ throw new Error(`createSingleResourceForm: "${object.nameSingular}" has no form-renderable fields.`);
1874
+ }
1875
+ const human = object.labelSingular;
1876
+ const slugBase = toSlug(object.nameSingular);
1877
+ const create = {
1878
+ ...base,
1879
+ name: `Add ${human}`,
1880
+ description: `Create one ${human.toLowerCase()}.`,
1881
+ };
1882
+ // Edit reuses create's fields, prefixed by a picker for WHICH record. The
1883
+ // record's id is identity, not an answer — it's a picker, never a text box
1884
+ // someone can retype into a different row.
1885
+ const editSourceKey = `${base.sourceKey}:edit`;
1886
+ const edit = {
1887
+ ...base,
1888
+ sourceKey: editSourceKey,
1889
+ name: `Edit ${human}`,
1890
+ slug: `${slugBase}-edit`,
1891
+ description: `Update an existing ${human.toLowerCase()}.`,
1892
+ fields: [recordIdentityField(object, "edit"), ...base.fields],
1893
+ settings: {
1894
+ ...base.settings,
1895
+ generatedFrom: {
1896
+ ...base.settings.generatedFrom,
1897
+ sourceKey: editSourceKey,
1898
+ },
1899
+ },
1900
+ };
1901
+ // Delete carries no data fields — a picker and a plain statement of
1902
+ // consequences. It intentionally keeps no `create_records` action: this form
1903
+ // removes a row, it doesn't make one.
1904
+ const deleteSourceKey = `${base.sourceKey}:delete`;
1905
+ const del = {
1906
+ ...base,
1907
+ sourceKey: deleteSourceKey,
1908
+ name: `Delete ${human}`,
1909
+ slug: `${slugBase}-delete`,
1910
+ description: describeDeleteImpact(object, relatedObjects ?? []),
1911
+ fields: [recordIdentityField(object, "delete")],
1912
+ settings: {
1913
+ ...base.settings,
1914
+ generatedFrom: {
1915
+ ...base.settings.generatedFrom,
1916
+ sourceKey: deleteSourceKey,
1917
+ },
1918
+ actions: [],
1919
+ },
1920
+ };
1921
+ // Relation targets, as declared. Informational — the generated pickers already
1922
+ // carry their own `collectionKey`, so nothing consumes this to wire anything.
1923
+ const dependencies = [
1924
+ ...new Set(object.fields
1925
+ .map((f) => f.relationTarget)
1926
+ .filter((t) => typeof t === "string")),
1927
+ ];
1928
+ return {
1929
+ collectionId: object.universalIdentifier,
1930
+ create,
1931
+ edit,
1932
+ delete: del,
1933
+ dependencies,
1934
+ };
1935
+ }
1936
+ function isCollectionDefinition(node) {
1937
+ // No casts: `typeof node === "object" && node !== null` narrows to `object`,
1938
+ // and `in` operates on that directly.
1939
+ return (typeof node === "object" &&
1940
+ node !== null &&
1941
+ !("type" in node) &&
1942
+ "nameSingular" in node &&
1943
+ "fields" in node);
1944
+ }
1945
+ function isSchemaBoundaryNode(node) {
1946
+ return (typeof node === "object" &&
1947
+ node !== null &&
1948
+ "type" in node &&
1949
+ node.type === "schema_boundary");
1950
+ }
1951
+ function walkTreeForCollections(boundaries, collections, featureTags, inheritedFeatureId) {
1952
+ const layoutNodes = [];
1953
+ for (const node of boundaries) {
1954
+ if (isSchemaBoundaryNode(node)) {
1955
+ const fid = node.meta.featureId;
1956
+ const children = walkTreeForCollections(node.children, collections, featureTags, fid);
1957
+ layoutNodes.push(...children);
1958
+ }
1959
+ else if (isCollectionDefinition(node)) {
1960
+ collections.push(node);
1961
+ if (inheritedFeatureId) {
1962
+ featureTags[node.nameSingular] = inheritedFeatureId;
1963
+ }
1964
+ layoutNodes.push({
1965
+ type: "render_collection",
1966
+ collectionId: node.nameSingular,
1967
+ });
1968
+ }
1969
+ else {
1970
+ const layoutNode = node;
1971
+ if (layoutNode.type === "form_step" ||
1972
+ layoutNode.type === "conditional_block" ||
1973
+ layoutNode.type === "multi_populator") {
1974
+ const children = walkTreeForCollections((layoutNode.children || []), collections, featureTags, inheritedFeatureId);
1975
+ layoutNodes.push({ ...layoutNode, children });
1976
+ }
1977
+ else if (layoutNode.type === "or_condition") {
1978
+ const branches = (layoutNode.branches || []).map((branch) => ({
1979
+ ...branch,
1980
+ nodes: walkTreeForCollections(branch.nodes, collections, featureTags, inheritedFeatureId),
1981
+ }));
1982
+ layoutNodes.push({ ...layoutNode, branches });
1983
+ }
1984
+ else if (layoutNode.type === "render_collection") {
1985
+ const col = collections.find((c) => c.nameSingular === layoutNode.collectionId);
1986
+ if (!col && collections.length > 0) {
1987
+ throw new Error(`[BusinessDash SDK] Layout references unknown collection "${layoutNode.collectionId}".`);
1988
+ }
1989
+ layoutNodes.push(layoutNode);
1990
+ }
1991
+ }
1992
+ }
1993
+ return layoutNodes;
1994
+ }
1995
+ /**
1996
+ * Compile a layout tree into a pure database schema + UI form tree.
1997
+ *
1998
+ * Unlike the previous API which required separate `collections` and `layout`
1999
+ * properties, this new signature accepts a flat array of `SchemaBoundary`,
2000
+ * `collection()` definitions, and layout containers — extracting collections
2001
+ * and the visual render graph from the same expression tree.
2002
+ *
2003
+ * ```ts
2004
+ * const result = createFormSchemaOrchestrator([
2005
+ * SchemaBoundary(
2006
+ * { featureId: "crm", name: "CRM" },
2007
+ * collection("contacts", { fields: { name: bd.text().required() } }),
2008
+ * ),
2009
+ * ], { defaultIdType: "uuid", consecutive: true });
2010
+ * ```
2011
+ */
2012
+ export function createFormSchemaOrchestrator(boundaries, options = {}) {
2013
+ if (!Array.isArray(boundaries)) {
2014
+ throw new Error("[BusinessDash SDK] createFormSchemaOrchestrator: `boundaries` must be an array of SchemaBoundaryNode | CollectionDefinition | LayoutNode.");
2015
+ }
2016
+ const collections = [];
2017
+ const featureTags = {};
2018
+ const uiFormTree = walkTreeForCollections(boundaries, collections, featureTags);
2019
+ if (collections.length === 0) {
2020
+ throw new Error("[BusinessDash SDK] createFormSchemaOrchestrator: No collections found. Pass collection() directly, inside an optional SchemaBoundary(), or under an optional layout container.");
2021
+ }
2022
+ const schemaOptions = options.defaultIdType !== undefined
2023
+ ? { defaultIdType: options.defaultIdType }
2024
+ : {};
2025
+ const compiledDatabaseSchema = createSchema({ collections }, schemaOptions);
2026
+ const dbCollectionMap = new Map();
2027
+ for (const col of compiledDatabaseSchema.objects) {
2028
+ dbCollectionMap.set(col.nameSingular, col);
2029
+ }
2030
+ function renderedCollectionIds(nodes) {
2031
+ const ids = [];
2032
+ for (const node of nodes) {
2033
+ if (node.type === "render_collection") {
2034
+ ids.push(node.collectionId);
2035
+ }
2036
+ else if (node.type === "form_step" ||
2037
+ node.type === "conditional_block" ||
2038
+ node.type === "multi_populator") {
2039
+ ids.push(...renderedCollectionIds(node.children));
2040
+ }
2041
+ else if (node.type === "or_condition") {
2042
+ for (const branch of node.branches) {
2043
+ ids.push(...renderedCollectionIds(branch.nodes));
2044
+ }
2045
+ }
2046
+ }
2047
+ return ids;
2048
+ }
2049
+ function generatedLayoutForms(nodes, collectionForms) {
2050
+ const generated = [];
2051
+ const objectByName = new Map(compiledDatabaseSchema.objects.map((object) => [
2052
+ object.nameSingular,
2053
+ object,
2054
+ ]));
2055
+ const formByObjectIdentifier = new Map(collectionForms.map((form) => [
2056
+ form.settings.generatedFrom.objectIdentifier,
2057
+ form,
2058
+ ]));
2059
+ const walk = (candidates) => {
2060
+ for (const node of candidates) {
2061
+ if (node.type === "form_step") {
2062
+ const collectionIds = [
2063
+ ...new Set(renderedCollectionIds(node.children)),
2064
+ ];
2065
+ // A one-collection step is already represented by its collection
2066
+ // candidate. Composite steps are additional CLI choices.
2067
+ if (collectionIds.length > 1) {
2068
+ const objects = collectionIds
2069
+ .map((id) => objectByName.get(id))
2070
+ .filter((object) => object !== undefined);
2071
+ const identifiers = new Set(objects.map((object) => object.universalIdentifier));
2072
+ const aliasByIdentifier = new Map(objects.map((object) => [
2073
+ object.universalIdentifier,
2074
+ object.nameSingular,
2075
+ ]));
2076
+ const fields = [];
2077
+ const creates = [];
2078
+ for (const object of objects) {
2079
+ const base = formByObjectIdentifier.get(object.universalIdentifier);
2080
+ if (!base)
2081
+ continue;
2082
+ const fieldMap = {
2083
+ ...base.settings.actions[0].config.composite.creates[0]
2084
+ .fieldMap,
2085
+ };
2086
+ const links = [];
2087
+ for (const schemaField of object.fields) {
2088
+ if (schemaField.type !== "RELATION" ||
2089
+ !schemaField.relationTarget ||
2090
+ !identifiers.has(schemaField.relationTarget)) {
2091
+ continue;
2092
+ }
2093
+ delete fieldMap[schemaField.name];
2094
+ const targetAlias = aliasByIdentifier.get(schemaField.relationTarget);
2095
+ if (targetAlias) {
2096
+ links.push({
2097
+ field: schemaField.name,
2098
+ to: `$${targetAlias}`,
2099
+ });
2100
+ }
2101
+ }
2102
+ for (const field of base.fields) {
2103
+ if (!(field.outputKey in fieldMap))
2104
+ continue;
2105
+ const outputKey = `${object.nameSingular}${field.outputKey
2106
+ .charAt(0)
2107
+ .toUpperCase()}${field.outputKey.slice(1)}`;
2108
+ fields.push({
2109
+ ...field,
2110
+ label: `${object.labelSingular}: ${field.label}`,
2111
+ outputKey,
2112
+ });
2113
+ }
2114
+ creates.push({
2115
+ alias: object.nameSingular,
2116
+ object: object.universalIdentifier,
2117
+ fieldMap,
2118
+ ...(links.length > 0 ? { links } : {}),
2119
+ });
2120
+ }
2121
+ if (fields.length > 0 && creates.length > 1) {
2122
+ const sourceKey = `data-model-layout:${node.name}`;
2123
+ generated.push({
2124
+ sourceKey,
2125
+ name: `${toLabel(node.name)} Form`,
2126
+ slug: `${toSlug(node.name)}-form`,
2127
+ description: node.helperText ??
2128
+ `Create related ${objects
2129
+ .map((object) => object.labelPlural.toLowerCase())
2130
+ .join(", ")} in one submission.`,
2131
+ fields,
2132
+ settings: {
2133
+ isConsecutive: options.consecutive ?? false,
2134
+ showProgress: options.progress ?? options.consecutive ?? false,
2135
+ ...(options.progress_style
2136
+ ? {
2137
+ progressStyle: options.progress_style === "progress_bar"
2138
+ ? "bar"
2139
+ : "steps",
2140
+ }
2141
+ : {}),
2142
+ generatedFrom: {
2143
+ kind: "data_model",
2144
+ sourceKey,
2145
+ objectIdentifiers: objects.map((object) => object.universalIdentifier),
2146
+ },
2147
+ actions: [
2148
+ {
2149
+ id: generateUniversalIdentifier(`biab:generated-form:${sourceKey}:action:create-records`),
2150
+ type: "create_records",
2151
+ enabled: true,
2152
+ mapping: {},
2153
+ config: { composite: { creates } },
2154
+ },
2155
+ ],
2156
+ },
2157
+ });
2158
+ }
2159
+ }
2160
+ walk(node.children);
2161
+ }
2162
+ else if (node.type === "conditional_block" ||
2163
+ node.type === "multi_populator") {
2164
+ walk(node.children);
2165
+ }
2166
+ else if (node.type === "or_condition") {
2167
+ for (const branch of node.branches) {
2168
+ walk(branch.nodes);
2169
+ }
2170
+ }
2171
+ }
2172
+ };
2173
+ walk(nodes);
2174
+ return generated;
2175
+ }
2176
+ function validateAndAlignLayout(nodes) {
2177
+ for (const node of nodes) {
2178
+ if (node.type === "render_collection") {
2179
+ if (!dbCollectionMap.has(node.collectionId)) {
2180
+ throw new Error(`[BusinessDash SDK] Form layout references collection "${node.collectionId}" ` +
2181
+ "which does not exist in the declared collections block.");
2182
+ }
2183
+ }
2184
+ if (node.type === "conditional_block") {
2185
+ for (const rule of node.rules) {
2186
+ const parts = rule.fieldRef.split(".");
2187
+ if (parts.length !== 2) {
2188
+ throw new Error(`[BusinessDash SDK] Conditional rule fieldRef "${rule.fieldRef}" must be in "collectionName.fieldName" format.`);
2189
+ }
2190
+ const targetCollection = parts[0];
2191
+ const targetField = parts[1];
2192
+ const collectionDef = dbCollectionMap.get(targetCollection);
2193
+ if (!collectionDef) {
2194
+ throw new Error(`[BusinessDash SDK] Conditional rule references collection "${targetCollection}" in fieldRef "${rule.fieldRef}" which does not exist.`);
2195
+ }
2196
+ const field = collectionDef.fields.find((f) => f.name === targetField);
2197
+ if (!field) {
2198
+ throw new Error(`[BusinessDash SDK] Conditional rule references field "${targetField}" in collection "${targetCollection}" which does not exist.`);
2199
+ }
2200
+ }
2201
+ }
2202
+ if (node.type === "multi_populator") {
2203
+ if (node.max_entries !== undefined &&
2204
+ (!Number.isInteger(node.max_entries) || node.max_entries < 1)) {
2205
+ throw new Error("[BusinessDash SDK] MultiPopulator max_entries must be a positive whole number.");
2206
+ }
2207
+ const renderedIds = [...new Set(renderedCollectionIds(node.children))];
2208
+ const output = node.output;
2209
+ if (!output) {
2210
+ if (renderedIds.length !== 1) {
2211
+ throw new Error("[BusinessDash SDK] MultiPopulator without an explicit output must contain exactly one child collection so separate-table storage can be inferred.");
2212
+ }
2213
+ node.output = {
2214
+ strategy: "separateTable",
2215
+ collectionId: renderedIds[0],
2216
+ };
2217
+ }
2218
+ else if (output.strategy === "separateTable") {
2219
+ if (node.entryFields !== undefined) {
2220
+ throw new Error("[BusinessDash SDK] MultiPopulator entryFields are only valid for JSONB output. Define secondary-table fields on the target collection.");
2221
+ }
2222
+ const collectionId = output.collectionId ??
2223
+ (renderedIds.length === 1 ? renderedIds[0] : undefined);
2224
+ if (!collectionId) {
2225
+ throw new Error("[BusinessDash SDK] MultiPopulator separateTable output requires collectionId unless exactly one child collection can be inferred.");
2226
+ }
2227
+ if (!dbCollectionMap.has(collectionId)) {
2228
+ throw new Error(`[BusinessDash SDK] MultiPopulator separateTable output references collection "${collectionId}" which does not exist.`);
2229
+ }
2230
+ if (renderedIds.length > 0 && !renderedIds.includes(collectionId)) {
2231
+ throw new Error(`[BusinessDash SDK] MultiPopulator separateTable output targets "${collectionId}", but that collection is not rendered by the repeater.`);
2232
+ }
2233
+ node.output = { strategy: "separateTable", collectionId };
2234
+ }
2235
+ else {
2236
+ if (node.children.length > 0) {
2237
+ throw new Error("[BusinessDash SDK] MultiPopulator JSONB output defines its repeated shape with entryFields and cannot also contain child collections or layout nodes.");
2238
+ }
2239
+ if (!node.entryFields || node.entryFields.length === 0) {
2240
+ throw new Error("[BusinessDash SDK] MultiPopulator JSONB output requires at least one entryFields declaration.");
2241
+ }
2242
+ const parts = output.fieldRef.split(".");
2243
+ if (parts.length !== 2) {
2244
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB fieldRef "${output.fieldRef}" must be in "collectionName.fieldName" format.`);
2245
+ }
2246
+ const targetCollection = parts[0];
2247
+ const targetField = parts[1];
2248
+ const collectionDef = dbCollectionMap.get(targetCollection);
2249
+ if (!collectionDef) {
2250
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB output references collection "${targetCollection}" which does not exist.`);
2251
+ }
2252
+ const field = collectionDef.fields.find((candidate) => candidate.name === targetField);
2253
+ if (!field) {
2254
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB output references field "${targetField}" in collection "${targetCollection}" which does not exist.`);
2255
+ }
2256
+ if (field.type !== "RAW_JSON" && field.type !== "ARRAY") {
2257
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB output field "${output.fieldRef}" must use bd.json(), bd.object(), or bd.array(); received "${field.type}".`);
2258
+ }
2259
+ if (field.strategy !== "jsonb") {
2260
+ throw new Error(`[BusinessDash SDK] MultiPopulator JSONB output field "${output.fieldRef}" must declare .strategy("jsonb").`);
2261
+ }
2262
+ }
2263
+ }
2264
+ if (node.type === "form_step" ||
2265
+ node.type === "conditional_block" ||
2266
+ node.type === "multi_populator") {
2267
+ validateAndAlignLayout(node.children);
2268
+ }
2269
+ else if (node.type === "or_condition") {
2270
+ for (const branch of node.branches) {
2271
+ validateAndAlignLayout(branch.nodes);
2272
+ }
2273
+ }
2274
+ }
2275
+ }
2276
+ validateAndAlignLayout(uiFormTree);
2277
+ const progress = options.progress ?? !!options.consecutive;
2278
+ const progressStyle = options.progress_style ?? (progress ? "multi-step" : undefined);
2279
+ const collectionForms = generateCollectionFormCandidates(compiledDatabaseSchema.objects, options);
2280
+ return {
2281
+ databaseSchema: compiledDatabaseSchema,
2282
+ uiFormTree,
2283
+ generatedForms: [
2284
+ ...generatedLayoutForms(uiFormTree, collectionForms),
2285
+ ...collectionForms,
2286
+ ],
2287
+ formControlSettings: {
2288
+ consecutive: options.consecutive ?? false,
2289
+ progress,
2290
+ ...(progressStyle !== undefined ? { progressStyle } : {}),
2291
+ },
2292
+ featureTags,
2293
+ };
2294
+ }
2295
+ //# sourceMappingURL=data-model-schema.js.map