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