@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.
- package/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- 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
|