db4ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/dist/cli/bin.d.ts +50 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +418 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/dashboard/App.d.ts +16 -0
- package/dist/cli/dashboard/App.d.ts.map +1 -0
- package/dist/cli/dashboard/App.js +116 -0
- package/dist/cli/dashboard/App.js.map +1 -0
- package/dist/cli/dashboard/components/index.d.ts +70 -0
- package/dist/cli/dashboard/components/index.d.ts.map +1 -0
- package/dist/cli/dashboard/components/index.js +192 -0
- package/dist/cli/dashboard/components/index.js.map +1 -0
- package/dist/cli/dashboard/hooks/index.d.ts +76 -0
- package/dist/cli/dashboard/hooks/index.d.ts.map +1 -0
- package/dist/cli/dashboard/hooks/index.js +201 -0
- package/dist/cli/dashboard/hooks/index.js.map +1 -0
- package/dist/cli/dashboard/index.d.ts +17 -0
- package/dist/cli/dashboard/index.d.ts.map +1 -0
- package/dist/cli/dashboard/index.js +16 -0
- package/dist/cli/dashboard/index.js.map +1 -0
- package/dist/cli/dashboard/types.d.ts +84 -0
- package/dist/cli/dashboard/types.d.ts.map +1 -0
- package/dist/cli/dashboard/types.js +5 -0
- package/dist/cli/dashboard/types.js.map +1 -0
- package/dist/cli/dashboard/views/index.d.ts +51 -0
- package/dist/cli/dashboard/views/index.d.ts.map +1 -0
- package/dist/cli/dashboard/views/index.js +72 -0
- package/dist/cli/dashboard/views/index.js.map +1 -0
- package/dist/cli/index.d.ts +16 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +48 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/runtime/index.d.ts +236 -0
- package/dist/cli/runtime/index.d.ts.map +1 -0
- package/dist/cli/runtime/index.js +705 -0
- package/dist/cli/runtime/index.js.map +1 -0
- package/dist/cli/scanner/index.d.ts +90 -0
- package/dist/cli/scanner/index.d.ts.map +1 -0
- package/dist/cli/scanner/index.js +640 -0
- package/dist/cli/scanner/index.js.map +1 -0
- package/dist/cli/seed/index.d.ts +160 -0
- package/dist/cli/seed/index.d.ts.map +1 -0
- package/dist/cli/seed/index.js +774 -0
- package/dist/cli/seed/index.js.map +1 -0
- package/dist/cli/sync/index.d.ts +197 -0
- package/dist/cli/sync/index.d.ts.map +1 -0
- package/dist/cli/sync/index.js +706 -0
- package/dist/cli/sync/index.js.map +1 -0
- package/dist/cli/terminal.d.ts +60 -0
- package/dist/cli/terminal.d.ts.map +1 -0
- package/dist/cli/terminal.js +210 -0
- package/dist/cli/terminal.js.map +1 -0
- package/dist/cli/workflow/index.d.ts +152 -0
- package/dist/cli/workflow/index.d.ts.map +1 -0
- package/dist/cli/workflow/index.js +308 -0
- package/dist/cli/workflow/index.js.map +1 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +47 -0
- package/dist/errors.js.map +1 -0
- package/dist/handlers.d.ts +147 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +39 -0
- package/dist/handlers.js.map +1 -0
- package/dist/index.d.ts +1281 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3164 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/docs/api-reference.mdx +3 -0
- package/docs/examples.mdx +3 -0
- package/docs/getting-started.mdx +3 -0
- package/docs/index.mdx +3 -0
- package/docs/schema-dsl.mdx +3 -0
- package/package.json +121 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3164 @@
|
|
|
1
|
+
// Import and re-export shared handler types
|
|
2
|
+
export { serializeFunction, } from './handlers';
|
|
3
|
+
import { serializeFunction } from './handlers';
|
|
4
|
+
/**
|
|
5
|
+
* Error thrown when schema validation fails.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* try {
|
|
9
|
+
* DB({ User: { name: 'User name' } }) // Missing $id or $context
|
|
10
|
+
* } catch (error) {
|
|
11
|
+
* if (error instanceof SchemaValidationError) {
|
|
12
|
+
* console.log(error.code) // 'MISSING_CONTEXT'
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
export class SchemaValidationError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'SchemaValidationError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Error thrown when API operations fail.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* try {
|
|
29
|
+
* await db.User('john-doe')
|
|
30
|
+
* } catch (error) {
|
|
31
|
+
* if (error instanceof APIError) {
|
|
32
|
+
* console.log(error.code) // 'GENERATION_FAILED'
|
|
33
|
+
* console.log(error.statusCode) // 404
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
export class APIError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
statusCode;
|
|
40
|
+
constructor(code, message, statusCode) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'APIError';
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.statusCode = statusCode;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function UI(components) {
|
|
48
|
+
return components;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse namespace from a $id URL.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* parseNamespaceFromId('https://db.sb/sales') // 'sales' (from path)
|
|
55
|
+
* parseNamespaceFromId('https://db4.ai/startups') // 'startups' (from path)
|
|
56
|
+
* parseNamespaceFromId('https://db.sb/org/team') // 'org/team' (from path)
|
|
57
|
+
* parseNamespaceFromId('https://startup.db.sb') // 'startup' (from subdomain)
|
|
58
|
+
* parseNamespaceFromId('https://startups.db.sb') // 'startups' (from subdomain)
|
|
59
|
+
* parseNamespaceFromId('https://db.sb/') // null
|
|
60
|
+
* parseNamespaceFromId('invalid') // null
|
|
61
|
+
*/
|
|
62
|
+
export function parseNamespaceFromId(id) {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(id);
|
|
65
|
+
// Remove leading slash and trailing slash
|
|
66
|
+
const path = url.pathname.replace(/^\//, '').replace(/\/$/, '');
|
|
67
|
+
if (path)
|
|
68
|
+
return path;
|
|
69
|
+
// No path - try to extract namespace from subdomain
|
|
70
|
+
// e.g., 'startup.db.sb' -> 'startup', 'startups.db4.ai' -> 'startups'
|
|
71
|
+
const hostParts = url.hostname.split('.');
|
|
72
|
+
// Need at least 3 parts for subdomain (e.g., 'startup.db.sb' or 'startups.db4.ai')
|
|
73
|
+
if (hostParts.length >= 3) {
|
|
74
|
+
const subdomain = hostParts[0];
|
|
75
|
+
// Exclude common non-namespace subdomains
|
|
76
|
+
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
|
|
77
|
+
return subdomain;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Reference Operator Constants
|
|
88
|
+
// ============================================================================
|
|
89
|
+
/**
|
|
90
|
+
* Reference operators used in the schema DSL.
|
|
91
|
+
*
|
|
92
|
+
* - `->` (FORWARD_EXACT): Forward exact reference - resolves by ID
|
|
93
|
+
* - `~>` (FORWARD_FUZZY): Forward fuzzy reference - resolves by semantic matching
|
|
94
|
+
* - `<-` (BACKWARD_EXACT): Backward exact reference - reverse lookup by ID
|
|
95
|
+
* - `<~` (BACKWARD_FUZZY): Backward fuzzy reference - reverse lookup by semantic matching
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* // Forward references (existing)
|
|
99
|
+
* { author: '->User' } // Exact forward ref
|
|
100
|
+
* { avatar: '~>Image' } // Fuzzy forward ref
|
|
101
|
+
*
|
|
102
|
+
* // Backward references (new)
|
|
103
|
+
* { posts: '<-Post' } // Find Posts that reference this entity
|
|
104
|
+
* { related: '<~Article' } // Find semantically related Articles
|
|
105
|
+
*/
|
|
106
|
+
export const OPERATORS = {
|
|
107
|
+
/** Forward exact reference: `->Type` - resolves to exact ID match */
|
|
108
|
+
FORWARD_EXACT: '->',
|
|
109
|
+
/** Forward fuzzy reference: `~>Type` - resolves by semantic similarity */
|
|
110
|
+
FORWARD_FUZZY: '~>',
|
|
111
|
+
/** Backward exact reference: `<-Type` - finds entities referencing this one */
|
|
112
|
+
BACKWARD_EXACT: '<-',
|
|
113
|
+
/** Backward fuzzy reference: `<~Type` - finds semantically related entities */
|
|
114
|
+
BACKWARD_FUZZY: '<~',
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Parse a reference operator prefix into its direction and mode/fuzzy properties.
|
|
118
|
+
*
|
|
119
|
+
* This helper function converts the two-character operator prefix into structured
|
|
120
|
+
* properties for building ReferenceField objects.
|
|
121
|
+
*
|
|
122
|
+
* @param prefix - The operator prefix (`->`, `~>`, `<-`, or `<~`)
|
|
123
|
+
* @returns Parsed operator with direction and either fuzzy (forward) or mode (backward)
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* parseOperator('->') // { direction: 'forward', fuzzy: false }
|
|
127
|
+
* parseOperator('~>') // { direction: 'forward', fuzzy: true }
|
|
128
|
+
* parseOperator('<-') // { direction: 'backward', mode: 'exact' }
|
|
129
|
+
* parseOperator('<~') // { direction: 'backward', mode: 'fuzzy' }
|
|
130
|
+
*/
|
|
131
|
+
export function parseOperator(prefix) {
|
|
132
|
+
switch (prefix) {
|
|
133
|
+
case OPERATORS.FORWARD_EXACT:
|
|
134
|
+
return { direction: 'forward', fuzzy: false };
|
|
135
|
+
case OPERATORS.FORWARD_FUZZY:
|
|
136
|
+
return { direction: 'forward', fuzzy: true };
|
|
137
|
+
case OPERATORS.BACKWARD_EXACT:
|
|
138
|
+
return { direction: 'backward', mode: 'exact' };
|
|
139
|
+
case OPERATORS.BACKWARD_FUZZY:
|
|
140
|
+
return { direction: 'backward', mode: 'fuzzy' };
|
|
141
|
+
default:
|
|
142
|
+
// Default to forward exact for unknown operators
|
|
143
|
+
return { direction: 'forward', fuzzy: false };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse $id field which can be:
|
|
148
|
+
* - Simple JSONPath: $.slug
|
|
149
|
+
* - Transform with path: PascalCase($.example)
|
|
150
|
+
* - Column name (legacy): slug
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* parseIdConfig('$.slug') // { path: '$.slug' }
|
|
154
|
+
* parseIdConfig('PascalCase($.example)') // { path: '$.example', transform: 'PascalCase' }
|
|
155
|
+
* parseIdConfig('kebab-case($.title)') // { path: '$.title', transform: 'kebab-case' }
|
|
156
|
+
* parseIdConfig('slug') // { path: 'slug' }
|
|
157
|
+
*/
|
|
158
|
+
export function parseIdConfig(idField) {
|
|
159
|
+
// Check for transform pattern: Transform($.path)
|
|
160
|
+
// Supports transforms like PascalCase, camelCase, kebab-case, UPPERCASE, lowercase
|
|
161
|
+
const transformMatch = idField.match(/^([A-Za-z][A-Za-z0-9-]*)\((\$\..+)\)$/);
|
|
162
|
+
if (transformMatch) {
|
|
163
|
+
const [, transform, path] = transformMatch;
|
|
164
|
+
return { path, transform };
|
|
165
|
+
}
|
|
166
|
+
// Plain JSONPath or column name
|
|
167
|
+
return { path: idField };
|
|
168
|
+
}
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// JSONPath Validation
|
|
171
|
+
// ============================================================================
|
|
172
|
+
/**
|
|
173
|
+
* Validates that a string is a valid JSONPath expression.
|
|
174
|
+
*
|
|
175
|
+
* A valid JSONPath must:
|
|
176
|
+
* - Start with `$.` (root reference)
|
|
177
|
+
* - Contain valid property names, array indices, or bracket notation
|
|
178
|
+
*
|
|
179
|
+
* Supported patterns:
|
|
180
|
+
* - `$.field` - Simple property access
|
|
181
|
+
* - `$.nested.field` - Nested property access
|
|
182
|
+
* - `$.array[0]` - Array index access
|
|
183
|
+
* - `$.array[*]` - Array wildcard
|
|
184
|
+
* - `$.items[?(@.active)]` - Filter expressions
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* isValidJSONPath('$.title') // true
|
|
188
|
+
* isValidJSONPath('$.data.items[0]') // true
|
|
189
|
+
* isValidJSONPath('$.tags[*]') // true
|
|
190
|
+
* isValidJSONPath('title') // false (missing $.)
|
|
191
|
+
* isValidJSONPath('$50 price') // false (not a valid path)
|
|
192
|
+
*/
|
|
193
|
+
export function isValidJSONPath(path) {
|
|
194
|
+
// Must start with $.
|
|
195
|
+
if (!path.startsWith('$.')) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
// Basic validation regex for common JSONPath patterns:
|
|
199
|
+
// - Property access: .propertyName or ['property name']
|
|
200
|
+
// - Array access: [0], [*], [?(@.condition)]
|
|
201
|
+
// - Nested paths: $.a.b.c
|
|
202
|
+
const jsonPathPattern = /^\$\.[\w\[\].*@?()'"=!<>|&\s-]+$/;
|
|
203
|
+
return jsonPathPattern.test(path);
|
|
204
|
+
}
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Type Guards for ParsedField
|
|
207
|
+
// ============================================================================
|
|
208
|
+
/**
|
|
209
|
+
* Type guard to check if a ParsedField is a JSONPathField.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* const field = parseFieldType('$.title')
|
|
213
|
+
* if (isJSONPathField(field)) {
|
|
214
|
+
* console.log(field.path) // TypeScript knows field.path exists
|
|
215
|
+
* }
|
|
216
|
+
*/
|
|
217
|
+
export function isJSONPathField(field) {
|
|
218
|
+
return field.type === 'jsonpath';
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Type guard to check if a ParsedField is a JSONPathEmbeddedField.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* const field = parseFieldType('$.author ->Author')
|
|
225
|
+
* if (isJSONPathEmbeddedField(field)) {
|
|
226
|
+
* console.log(field.embeddedType) // TypeScript knows field.embeddedType exists
|
|
227
|
+
* }
|
|
228
|
+
*/
|
|
229
|
+
export function isJSONPathEmbeddedField(field) {
|
|
230
|
+
return field.type === 'jsonpath-embedded';
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Type guard to check if a ParsedField is a PromptField.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* const field = parsed.types.Task.fields.description
|
|
237
|
+
* if (isPromptField(field)) {
|
|
238
|
+
* console.log(field.prompt) // TypeScript knows field.prompt exists
|
|
239
|
+
* }
|
|
240
|
+
*/
|
|
241
|
+
export function isPromptField(field) {
|
|
242
|
+
return field.type === 'prompt';
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Type guard to check if a ParsedField is a StringField.
|
|
246
|
+
*/
|
|
247
|
+
export function isStringField(field) {
|
|
248
|
+
return field.type === 'string';
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Validate a raw state machine configuration.
|
|
252
|
+
* Checks for: valid $initial, reachable states, no orphan states, valid transitions.
|
|
253
|
+
*/
|
|
254
|
+
export function validateStateConfig(config) {
|
|
255
|
+
const errors = [];
|
|
256
|
+
// Extract state names (exclude $ prefixed keys like $initial, $version, etc.)
|
|
257
|
+
const stateNames = Object.keys(config).filter((k) => !k.startsWith('$'));
|
|
258
|
+
// Check if $initial is missing or empty
|
|
259
|
+
if (!('$initial' in config) || config.$initial === undefined) {
|
|
260
|
+
errors.push({
|
|
261
|
+
code: 'MISSING_INITIAL_STATE',
|
|
262
|
+
message: 'State machine configuration is missing $initial',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
else if (config.$initial === '' || !stateNames.includes(config.$initial)) {
|
|
266
|
+
// $initial is present but invalid (empty string or references non-existent state)
|
|
267
|
+
errors.push({
|
|
268
|
+
code: 'INVALID_INITIAL_STATE',
|
|
269
|
+
message: `Initial state '${config.$initial}' is not a valid state`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// Helper to get transition target from a transition value
|
|
273
|
+
const getTransitionTarget = (value) => {
|
|
274
|
+
if (typeof value === 'string')
|
|
275
|
+
return value;
|
|
276
|
+
if (value && typeof value === 'object' && 'to' in value && typeof value.to === 'string') {
|
|
277
|
+
return value.to;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
};
|
|
281
|
+
// Check all transition targets exist and have valid syntax
|
|
282
|
+
for (const stateName of stateNames) {
|
|
283
|
+
const stateConfig = config[stateName];
|
|
284
|
+
// null means terminal state - valid
|
|
285
|
+
if (stateConfig === null)
|
|
286
|
+
continue;
|
|
287
|
+
// Empty object means terminal state - valid
|
|
288
|
+
if (typeof stateConfig === 'object' && Object.keys(stateConfig).length === 0)
|
|
289
|
+
continue;
|
|
290
|
+
if (typeof stateConfig !== 'object') {
|
|
291
|
+
errors.push({
|
|
292
|
+
code: 'INVALID_STATE_CONFIG',
|
|
293
|
+
message: `State '${stateName}' has invalid configuration`,
|
|
294
|
+
state: stateName,
|
|
295
|
+
});
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Check each transition
|
|
299
|
+
for (const [eventName, transitionValue] of Object.entries(stateConfig)) {
|
|
300
|
+
// Skip handler functions ($entry, $exit)
|
|
301
|
+
if (eventName.startsWith('$'))
|
|
302
|
+
continue;
|
|
303
|
+
// Check transition syntax
|
|
304
|
+
if (Array.isArray(transitionValue)) {
|
|
305
|
+
errors.push({
|
|
306
|
+
code: 'INVALID_TRANSITION_SYNTAX',
|
|
307
|
+
message: `Transition '${eventName}' in state '${stateName}' cannot be an array`,
|
|
308
|
+
state: stateName,
|
|
309
|
+
event: eventName,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// String transition: { submit: 'pending' }
|
|
314
|
+
if (typeof transitionValue === 'string') {
|
|
315
|
+
if (!stateNames.includes(transitionValue)) {
|
|
316
|
+
errors.push({
|
|
317
|
+
code: 'INVALID_TRANSITION_TARGET',
|
|
318
|
+
message: `Transition '${eventName}' in state '${stateName}' targets unknown state '${transitionValue}'`,
|
|
319
|
+
state: stateName,
|
|
320
|
+
event: eventName,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// null transition: terminal (goes nowhere, like archive/delete)
|
|
326
|
+
if (transitionValue === null)
|
|
327
|
+
continue;
|
|
328
|
+
// Object transition: { submit: { to: 'pending', if: ..., do: ... } }
|
|
329
|
+
if (typeof transitionValue === 'object') {
|
|
330
|
+
const tv = transitionValue;
|
|
331
|
+
// Must have 'to' property
|
|
332
|
+
if (!('to' in tv)) {
|
|
333
|
+
errors.push({
|
|
334
|
+
code: 'INVALID_TRANSITION_SYNTAX',
|
|
335
|
+
message: `Transition '${eventName}' in state '${stateName}' is missing 'to' property`,
|
|
336
|
+
state: stateName,
|
|
337
|
+
event: eventName,
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// Check target exists if 'to' is specified
|
|
342
|
+
if (typeof tv.to === 'string' && !stateNames.includes(tv.to)) {
|
|
343
|
+
errors.push({
|
|
344
|
+
code: 'INVALID_TRANSITION_TARGET',
|
|
345
|
+
message: `Transition '${eventName}' in state '${stateName}' targets unknown state '${tv.to}'`,
|
|
346
|
+
state: stateName,
|
|
347
|
+
event: eventName,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// Unknown transition format (e.g., number)
|
|
353
|
+
errors.push({
|
|
354
|
+
code: 'INVALID_TRANSITION_SYNTAX',
|
|
355
|
+
message: `Transition '${eventName}' in state '${stateName}' has invalid format`,
|
|
356
|
+
state: stateName,
|
|
357
|
+
event: eventName,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Check for orphan states (states not reachable from $initial)
|
|
362
|
+
// Only do this check if we have a valid $initial state
|
|
363
|
+
if (config.$initial && stateNames.includes(config.$initial)) {
|
|
364
|
+
const reachable = new Set();
|
|
365
|
+
const toVisit = [config.$initial];
|
|
366
|
+
while (toVisit.length > 0) {
|
|
367
|
+
const current = toVisit.pop();
|
|
368
|
+
if (reachable.has(current))
|
|
369
|
+
continue;
|
|
370
|
+
reachable.add(current);
|
|
371
|
+
// Find all states reachable from current
|
|
372
|
+
const stateConfig = config[current];
|
|
373
|
+
if (stateConfig === null || typeof stateConfig !== 'object')
|
|
374
|
+
continue;
|
|
375
|
+
for (const [eventName, transitionValue] of Object.entries(stateConfig)) {
|
|
376
|
+
if (eventName.startsWith('$'))
|
|
377
|
+
continue;
|
|
378
|
+
const target = getTransitionTarget(transitionValue);
|
|
379
|
+
if (target && stateNames.includes(target) && !reachable.has(target)) {
|
|
380
|
+
toVisit.push(target);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Any state not in reachable set is an orphan
|
|
385
|
+
for (const stateName of stateNames) {
|
|
386
|
+
if (!reachable.has(stateName)) {
|
|
387
|
+
errors.push({
|
|
388
|
+
code: 'ORPHAN_STATE',
|
|
389
|
+
message: `State '${stateName}' is not reachable from initial state '${config.$initial}'`,
|
|
390
|
+
state: stateName,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { valid: errors.length === 0, errors };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Parse a raw state machine configuration into the normalized format.
|
|
399
|
+
* Converts string functions to SerializedFunction objects.
|
|
400
|
+
*
|
|
401
|
+
* @param config - Raw state machine config from schema
|
|
402
|
+
* @returns Parsed state machine configuration
|
|
403
|
+
*
|
|
404
|
+
* @example
|
|
405
|
+
* const parsed = parseStateConfig({
|
|
406
|
+
* $initial: 'draft',
|
|
407
|
+
* draft: {
|
|
408
|
+
* submit: 'pending',
|
|
409
|
+
* $entry: (entity) => { console.log('entered draft') }
|
|
410
|
+
* },
|
|
411
|
+
* pending: {
|
|
412
|
+
* approve: { to: 'approved', if: (e) => e.score >= 80 }
|
|
413
|
+
* },
|
|
414
|
+
* approved: null // terminal state
|
|
415
|
+
* })
|
|
416
|
+
*/
|
|
417
|
+
export function parseStateConfig(config) {
|
|
418
|
+
const states = {};
|
|
419
|
+
for (const [stateName, stateValue] of Object.entries(config)) {
|
|
420
|
+
// Skip $initial - it's not a state
|
|
421
|
+
if (stateName === '$initial')
|
|
422
|
+
continue;
|
|
423
|
+
// Terminal state (null or empty object)
|
|
424
|
+
if (stateValue === null || (typeof stateValue === 'object' && Object.keys(stateValue).length === 0)) {
|
|
425
|
+
states[stateName] = { transitions: {} };
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (typeof stateValue !== 'object') {
|
|
429
|
+
continue; // Skip invalid state configs
|
|
430
|
+
}
|
|
431
|
+
const stateConfig = { transitions: {} };
|
|
432
|
+
const stateObj = stateValue;
|
|
433
|
+
for (const [key, value] of Object.entries(stateObj)) {
|
|
434
|
+
// Handle $entry handler
|
|
435
|
+
if (key === '$entry') {
|
|
436
|
+
if (typeof value === 'function') {
|
|
437
|
+
stateConfig.$entry = serializeFunction('$entry', value);
|
|
438
|
+
}
|
|
439
|
+
else if (typeof value === 'string') {
|
|
440
|
+
// String function body - parse it
|
|
441
|
+
stateConfig.$entry = {
|
|
442
|
+
name: '$entry',
|
|
443
|
+
body: value,
|
|
444
|
+
params: ['entity'],
|
|
445
|
+
async: value.includes('await'),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// Handle $exit handler
|
|
451
|
+
if (key === '$exit') {
|
|
452
|
+
if (typeof value === 'function') {
|
|
453
|
+
stateConfig.$exit = serializeFunction('$exit', value);
|
|
454
|
+
}
|
|
455
|
+
else if (typeof value === 'string') {
|
|
456
|
+
stateConfig.$exit = {
|
|
457
|
+
name: '$exit',
|
|
458
|
+
body: value,
|
|
459
|
+
params: ['entity'],
|
|
460
|
+
async: value.includes('await'),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// Handle transitions
|
|
466
|
+
const transitionName = key;
|
|
467
|
+
// Simple string transition: { submit: 'pending' }
|
|
468
|
+
if (typeof value === 'string') {
|
|
469
|
+
stateConfig.transitions[transitionName] = { to: value };
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// null transition (shouldn't happen, but handle it)
|
|
473
|
+
if (value === null) {
|
|
474
|
+
stateConfig.transitions[transitionName] = { to: null };
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
// Complex transition with to/if/do: { approve: { to: 'approved', if: ..., do: ... } }
|
|
478
|
+
if (typeof value === 'object') {
|
|
479
|
+
const transObj = value;
|
|
480
|
+
const transitionConfig = {
|
|
481
|
+
to: transObj.to ?? null,
|
|
482
|
+
};
|
|
483
|
+
// Parse 'if' guard
|
|
484
|
+
if (transObj.if) {
|
|
485
|
+
if (typeof transObj.if === 'function') {
|
|
486
|
+
transitionConfig.if = serializeFunction('if', transObj.if);
|
|
487
|
+
}
|
|
488
|
+
else if (typeof transObj.if === 'string') {
|
|
489
|
+
transitionConfig.if = {
|
|
490
|
+
name: 'if',
|
|
491
|
+
body: transObj.if,
|
|
492
|
+
params: ['entity'],
|
|
493
|
+
async: transObj.if.includes('await'),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Parse 'do' action
|
|
498
|
+
if (transObj.do) {
|
|
499
|
+
if (typeof transObj.do === 'function') {
|
|
500
|
+
transitionConfig.do = serializeFunction('do', transObj.do);
|
|
501
|
+
}
|
|
502
|
+
else if (typeof transObj.do === 'string') {
|
|
503
|
+
transitionConfig.do = {
|
|
504
|
+
name: 'do',
|
|
505
|
+
body: transObj.do,
|
|
506
|
+
params: ['entity'],
|
|
507
|
+
async: transObj.do.includes('await'),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
stateConfig.transitions[transitionName] = transitionConfig;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
states[stateName] = stateConfig;
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
$initial: config.$initial,
|
|
518
|
+
states,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// Extract count from array description
|
|
522
|
+
// Supports patterns like:
|
|
523
|
+
// - "List 5 items" -> 5
|
|
524
|
+
// - "Generate 10 samples" -> 10
|
|
525
|
+
// - "Create exactly 7 categories" -> 7
|
|
526
|
+
// - "List 3-5 keywords" -> { min: 3, max: 5 }
|
|
527
|
+
export function extractCountFromDescription(description) {
|
|
528
|
+
// First check for range pattern: N-M (e.g., "3-5")
|
|
529
|
+
const rangeMatch = description.match(/\b(\d+)-(\d+)\b/);
|
|
530
|
+
if (rangeMatch) {
|
|
531
|
+
return { min: parseInt(rangeMatch[1], 10), max: parseInt(rangeMatch[2], 10) };
|
|
532
|
+
}
|
|
533
|
+
// Check for patterns like "List N", "Generate N", "Create exactly N"
|
|
534
|
+
// Match word followed by optional "exactly" and a number
|
|
535
|
+
const countMatch = description.match(/\b(?:List|Generate|Create)\s+(?:exactly\s+)?(\d+)\b/i);
|
|
536
|
+
if (countMatch) {
|
|
537
|
+
return parseInt(countMatch[1], 10);
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Parse a field value into its structured field type representation.
|
|
543
|
+
*
|
|
544
|
+
* Converts the shorthand field notation used in schema definitions into
|
|
545
|
+
* a fully parsed field object with type information, references, and options.
|
|
546
|
+
*
|
|
547
|
+
* ## Reference Operators
|
|
548
|
+
*
|
|
549
|
+
* This function parses all four reference operators defined in {@link OPERATORS}:
|
|
550
|
+
*
|
|
551
|
+
* | Operator | Direction | Mode | Description |
|
|
552
|
+
* |----------|-----------|-------|--------------------------------|
|
|
553
|
+
* | `->` | forward | exact | Resolves to exact ID match |
|
|
554
|
+
* | `~>` | forward | fuzzy | Resolves by semantic similarity|
|
|
555
|
+
* | `<-` | backward | exact | Finds entities referencing this|
|
|
556
|
+
* | `<~` | backward | fuzzy | Finds semantically related |
|
|
557
|
+
*
|
|
558
|
+
* @param value - The field value to parse. Can be:
|
|
559
|
+
* - `string` - A field description or reference pattern (e.g., `'User name'`, `'->User'`, `'~>Image?'`, `'<-Post[]'`)
|
|
560
|
+
* - `string[]` - An array field with description (e.g., `['Tag labels']`, `['<-Post']`)
|
|
561
|
+
* - `Record<string, string>` - A nested object with field descriptions
|
|
562
|
+
* - `Function` - A computed field
|
|
563
|
+
*
|
|
564
|
+
* @returns A ParsedField object representing the field type:
|
|
565
|
+
* - `StringField` - Plain text field with description
|
|
566
|
+
* - `ReferenceField` - Reference to another type (forward or backward)
|
|
567
|
+
* - `ArrayField` - Array of strings or references
|
|
568
|
+
* - `ObjectField` - Nested object with properties
|
|
569
|
+
* - `EmbeddedField` - Embedded type (detected in second pass)
|
|
570
|
+
* - `JSONPathField` - JSONPath expression for data mapping
|
|
571
|
+
* - `ComputedField` - Field computed from a function
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* // String field
|
|
575
|
+
* parseFieldType('User name')
|
|
576
|
+
* // { type: 'string', description: 'User name' }
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* // Forward exact reference
|
|
580
|
+
* parseFieldType('->User')
|
|
581
|
+
* // { type: 'reference', ref: 'User', isArray: false, optional: false, fuzzy: false }
|
|
582
|
+
*
|
|
583
|
+
* @example
|
|
584
|
+
* // Forward fuzzy reference (optional)
|
|
585
|
+
* parseFieldType('~>Image?')
|
|
586
|
+
* // { type: 'reference', ref: 'Image', isArray: false, optional: true, fuzzy: true }
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* // Forward array reference
|
|
590
|
+
* parseFieldType('->Tag[]')
|
|
591
|
+
* // { type: 'reference', ref: 'Tag', isArray: true, optional: false, fuzzy: false }
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* // Backward exact reference - finds Posts that reference this entity
|
|
595
|
+
* parseFieldType('<-Post')
|
|
596
|
+
* // { type: 'reference', ref: 'Post', isArray: false, optional: false, direction: 'backward', mode: 'exact' }
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* // Backward fuzzy reference array - finds semantically related Articles
|
|
600
|
+
* parseFieldType('<~Article[]')
|
|
601
|
+
* // { type: 'reference', ref: 'Article', isArray: true, optional: false, direction: 'backward', mode: 'fuzzy' }
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* // Union type reference
|
|
605
|
+
* parseFieldType('->User|Organization')
|
|
606
|
+
* // { type: 'reference', refs: ['User', 'Organization'], isArray: false, optional: false, fuzzy: false }
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* // String array with count hint
|
|
610
|
+
* parseFieldType(['List 5 keywords'])
|
|
611
|
+
* // { type: 'array', items: { type: 'string' }, description: 'List 5 keywords', count: 5 }
|
|
612
|
+
*
|
|
613
|
+
* @example
|
|
614
|
+
* // Nested object
|
|
615
|
+
* parseFieldType({ street: 'Street address', city: 'City name' })
|
|
616
|
+
* // { type: 'object', properties: { street: {...}, city: {...} } }
|
|
617
|
+
*/
|
|
618
|
+
export function parseFieldType(value) {
|
|
619
|
+
// Handle computed fields (functions)
|
|
620
|
+
if (typeof value === 'function') {
|
|
621
|
+
return {
|
|
622
|
+
type: 'computed',
|
|
623
|
+
source: value.toString(),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// Handle array descriptions: ['description'], ['... ->Type'], ['<-Type']
|
|
627
|
+
if (Array.isArray(value)) {
|
|
628
|
+
const desc = value[0] || '';
|
|
629
|
+
const count = extractCountFromDescription(desc);
|
|
630
|
+
// Check for back reference: ['<-Type']
|
|
631
|
+
const backRefMatch = desc.match(/^<-(\w+)$/);
|
|
632
|
+
if (backRefMatch) {
|
|
633
|
+
const result = {
|
|
634
|
+
type: 'array',
|
|
635
|
+
items: { type: 'string' },
|
|
636
|
+
description: desc,
|
|
637
|
+
backRef: backRefMatch[1],
|
|
638
|
+
};
|
|
639
|
+
if (count !== undefined)
|
|
640
|
+
result.count = count;
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
// Check for inline references: ['... ->Type'] or ['... ->Type1 ->Type2']
|
|
644
|
+
const inlineRefMatches = desc.match(/->(\w+)/g);
|
|
645
|
+
if (inlineRefMatches) {
|
|
646
|
+
const refs = inlineRefMatches.map((m) => m.slice(2)); // Remove '->' prefix
|
|
647
|
+
const result = {
|
|
648
|
+
type: 'array',
|
|
649
|
+
items: { type: 'string' },
|
|
650
|
+
description: desc,
|
|
651
|
+
refs,
|
|
652
|
+
};
|
|
653
|
+
if (count !== undefined)
|
|
654
|
+
result.count = count;
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
const result = {
|
|
658
|
+
type: 'array',
|
|
659
|
+
items: { type: 'string' },
|
|
660
|
+
description: desc,
|
|
661
|
+
};
|
|
662
|
+
if (count !== undefined)
|
|
663
|
+
result.count = count;
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
// Handle nested objects: { field: 'description', ... }
|
|
667
|
+
if (typeof value === 'object' && value !== null) {
|
|
668
|
+
const properties = {};
|
|
669
|
+
for (const [key, desc] of Object.entries(value)) {
|
|
670
|
+
properties[key] = { type: 'string', description: desc };
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
type: 'object',
|
|
674
|
+
properties,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// Handle string patterns
|
|
678
|
+
const str = value;
|
|
679
|
+
// Check for JSONPath with transform: Transform($.path)
|
|
680
|
+
// Supports transforms like PascalCase, camelCase, kebab-case, UPPERCASE, lowercase
|
|
681
|
+
const transformMatch = str.match(/^([A-Za-z][A-Za-z0-9-]*)\((\$\..+)\)$/);
|
|
682
|
+
if (transformMatch) {
|
|
683
|
+
const [, transform, path] = transformMatch;
|
|
684
|
+
return {
|
|
685
|
+
type: 'jsonpath',
|
|
686
|
+
path,
|
|
687
|
+
transform,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
// Check for JSONPath embedded pattern: $.path ->Type or $.path ->Type?
|
|
691
|
+
// Must check this BEFORE plain JSONPath to properly detect embedded mappings
|
|
692
|
+
// Pattern: JSONPath followed by optional whitespace, ->, optional whitespace, TypeName, optional ?
|
|
693
|
+
const jsonpathEmbeddedMatch = str.match(/^(\$\.[^\s]+?)\s*->\s*([A-Za-z][A-Za-z0-9]*)(\?)?$/);
|
|
694
|
+
if (jsonpathEmbeddedMatch) {
|
|
695
|
+
const [, path, embeddedType, optionalMarker] = jsonpathEmbeddedMatch;
|
|
696
|
+
// Determine if it's an array based on path containing [*] or [?(...)]
|
|
697
|
+
const isArray = /\[\*\]|\[\?/.test(path);
|
|
698
|
+
return {
|
|
699
|
+
type: 'jsonpath-embedded',
|
|
700
|
+
path,
|
|
701
|
+
embeddedType,
|
|
702
|
+
isArray,
|
|
703
|
+
...(optionalMarker && { optional: true }),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
// Check for JSONPath pattern: $.xxx (must start with $. to avoid matching "$50 price")
|
|
707
|
+
if (str.startsWith('$.')) {
|
|
708
|
+
return {
|
|
709
|
+
type: 'jsonpath',
|
|
710
|
+
path: str,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
// Check for reference pattern: ->Type, ~>Type, <-Type, <~Type, with optional [], ?, and unions
|
|
714
|
+
// Pattern: (<-|<~|->|~>)Type1|Type2|...[]?
|
|
715
|
+
const refMatch = str.match(/^(<-|<~|->|~>)(.+?)(\[\])?(\?)?$/);
|
|
716
|
+
if (refMatch) {
|
|
717
|
+
const [, prefix, typesPart, arrayMarker, optionalMarker] = refMatch;
|
|
718
|
+
const isArray = !!arrayMarker;
|
|
719
|
+
const optional = !!optionalMarker;
|
|
720
|
+
// Check for union types (Type1|Type2|...)
|
|
721
|
+
const types = typesPart.split('|').map((t) => t.trim());
|
|
722
|
+
// Parse the operator to get direction and mode/fuzzy
|
|
723
|
+
const parsedOp = parseOperator(prefix);
|
|
724
|
+
if (parsedOp.direction === 'backward') {
|
|
725
|
+
// Backward references use direction/mode properties
|
|
726
|
+
const baseRef = {
|
|
727
|
+
type: 'reference',
|
|
728
|
+
isArray,
|
|
729
|
+
optional,
|
|
730
|
+
direction: parsedOp.direction,
|
|
731
|
+
mode: parsedOp.mode,
|
|
732
|
+
};
|
|
733
|
+
return types.length > 1 ? { ...baseRef, refs: types } : { ...baseRef, ref: types[0] };
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
// Forward references use fuzzy property (backward compatibility)
|
|
737
|
+
const baseRef = {
|
|
738
|
+
type: 'reference',
|
|
739
|
+
isArray,
|
|
740
|
+
optional,
|
|
741
|
+
fuzzy: parsedOp.fuzzy,
|
|
742
|
+
};
|
|
743
|
+
return types.length > 1 ? { ...baseRef, refs: types } : { ...baseRef, ref: types[0] };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Plain string field
|
|
747
|
+
return {
|
|
748
|
+
type: 'string',
|
|
749
|
+
description: str,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Parse a reference field definition for generation.
|
|
754
|
+
*
|
|
755
|
+
* Handles patterns like:
|
|
756
|
+
* - `->Person` - exact reference
|
|
757
|
+
* - `~>Industry` - fuzzy reference
|
|
758
|
+
* - `->Type[]` - array of exact references
|
|
759
|
+
* - `~>Type?` - optional fuzzy reference
|
|
760
|
+
* - `->User|Organization` - union type reference
|
|
761
|
+
*
|
|
762
|
+
* @param fieldDef - The field definition string (e.g., '->Person', '~>Industry?')
|
|
763
|
+
* @returns ParsedGenerationReference or null if not a reference field
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* parseGenerationReference('->Person')
|
|
767
|
+
* // { mode: 'exact', targetType: 'Person', isOptional: false, isArray: false }
|
|
768
|
+
*
|
|
769
|
+
* @example
|
|
770
|
+
* parseGenerationReference('~>Industry?')
|
|
771
|
+
* // { mode: 'fuzzy', targetType: 'Industry', isOptional: true, isArray: false }
|
|
772
|
+
*
|
|
773
|
+
* @example
|
|
774
|
+
* parseGenerationReference('->User|Organization')
|
|
775
|
+
* // { mode: 'exact', targetType: 'User', targetTypes: ['User', 'Organization'], isOptional: false, isArray: false }
|
|
776
|
+
*/
|
|
777
|
+
export function parseGenerationReference(fieldDef) {
|
|
778
|
+
// Match reference patterns: ->, ~>, with optional ? and []
|
|
779
|
+
// Pattern supports union types: ->Type1|Type2|Type3
|
|
780
|
+
const match = fieldDef.match(/^(->|~>)([A-Za-z][A-Za-z0-9]*(?:\|[A-Za-z][A-Za-z0-9]*)*)(\[\])?(\?)?$/);
|
|
781
|
+
if (!match)
|
|
782
|
+
return null;
|
|
783
|
+
const [, operator, typesPart, arrayMarker, optionalMarker] = match;
|
|
784
|
+
const types = typesPart.split('|').map(t => t.trim());
|
|
785
|
+
return {
|
|
786
|
+
mode: operator === '->' ? 'exact' : 'fuzzy',
|
|
787
|
+
targetType: types[0],
|
|
788
|
+
...(types.length > 1 && { targetTypes: types }),
|
|
789
|
+
isOptional: optionalMarker === '?',
|
|
790
|
+
isArray: arrayMarker === '[]',
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Parse array field definition for generation.
|
|
795
|
+
*
|
|
796
|
+
* Handles array shorthand like `['~>Investor']` or `['->Person']`.
|
|
797
|
+
*
|
|
798
|
+
* @param fieldDef - The field definition array (e.g., ['~>Investor'])
|
|
799
|
+
* @returns ParsedGenerationReference or null if not a reference array
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* parseGenerationArrayReference(['~>Investor'])
|
|
803
|
+
* // { mode: 'fuzzy', targetType: 'Investor', isOptional: false, isArray: true }
|
|
804
|
+
*/
|
|
805
|
+
export function parseGenerationArrayReference(fieldDef) {
|
|
806
|
+
if (!Array.isArray(fieldDef) || fieldDef.length !== 1)
|
|
807
|
+
return null;
|
|
808
|
+
const inner = fieldDef[0];
|
|
809
|
+
if (typeof inner !== 'string')
|
|
810
|
+
return null;
|
|
811
|
+
const parsed = parseGenerationReference(inner);
|
|
812
|
+
if (!parsed)
|
|
813
|
+
return null;
|
|
814
|
+
return {
|
|
815
|
+
...parsed,
|
|
816
|
+
isArray: true,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Check if a field definition is a reference field.
|
|
821
|
+
*
|
|
822
|
+
* @param fieldDef - The field definition (string or array)
|
|
823
|
+
* @returns true if this is a reference field
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* isReferenceField('->User') // true
|
|
827
|
+
* isReferenceField('~>Industry?') // true
|
|
828
|
+
* isReferenceField(['~>Investor']) // true
|
|
829
|
+
* isReferenceField('User name') // false
|
|
830
|
+
* isReferenceField(['tags']) // false
|
|
831
|
+
*/
|
|
832
|
+
export function isReferenceField(fieldDef) {
|
|
833
|
+
if (typeof fieldDef === 'string') {
|
|
834
|
+
return parseGenerationReference(fieldDef) !== null;
|
|
835
|
+
}
|
|
836
|
+
if (Array.isArray(fieldDef)) {
|
|
837
|
+
return parseGenerationArrayReference(fieldDef) !== null;
|
|
838
|
+
}
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Extract reference mode from a field definition.
|
|
843
|
+
*
|
|
844
|
+
* @param fieldDef - The field definition
|
|
845
|
+
* @returns 'exact' | 'fuzzy' | null
|
|
846
|
+
*/
|
|
847
|
+
export function getReferenceMode(fieldDef) {
|
|
848
|
+
if (typeof fieldDef === 'string') {
|
|
849
|
+
return parseGenerationReference(fieldDef)?.mode ?? null;
|
|
850
|
+
}
|
|
851
|
+
if (Array.isArray(fieldDef)) {
|
|
852
|
+
return parseGenerationArrayReference(fieldDef)?.mode ?? null;
|
|
853
|
+
}
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Extract target type(s) from a reference field.
|
|
858
|
+
*
|
|
859
|
+
* @param fieldDef - The field definition
|
|
860
|
+
* @returns Target type string, array of types for unions, or null if not a reference
|
|
861
|
+
*/
|
|
862
|
+
export function getReferenceTargetType(fieldDef) {
|
|
863
|
+
let parsed = null;
|
|
864
|
+
if (typeof fieldDef === 'string') {
|
|
865
|
+
parsed = parseGenerationReference(fieldDef);
|
|
866
|
+
}
|
|
867
|
+
else if (Array.isArray(fieldDef)) {
|
|
868
|
+
parsed = parseGenerationArrayReference(fieldDef);
|
|
869
|
+
}
|
|
870
|
+
if (!parsed)
|
|
871
|
+
return null;
|
|
872
|
+
return parsed.targetTypes ?? parsed.targetType;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Parse seed configuration from a schema type's `$seed` field.
|
|
876
|
+
*
|
|
877
|
+
* Seed configuration tells the system where to fetch initial data for a type,
|
|
878
|
+
* supporting TSV, CSV, and JSON formats with pagination support.
|
|
879
|
+
*
|
|
880
|
+
* @param value - The seed configuration. Can be:
|
|
881
|
+
* - `string` - A URL (format inferred from extension: `.csv`, `.json`, or default `.tsv`)
|
|
882
|
+
* - `Record<string, string>` - Object with explicit options
|
|
883
|
+
*
|
|
884
|
+
* @returns A SeedConfig object with:
|
|
885
|
+
* - `url` - The data source URL
|
|
886
|
+
* - `format` - Data format ('tsv', 'csv', or 'json')
|
|
887
|
+
* - `idField` - Optional field to use as entity ID
|
|
888
|
+
* - `next` - Optional JSONPath to pagination URL
|
|
889
|
+
* - `data` - Optional JSONPath to data array in response
|
|
890
|
+
*
|
|
891
|
+
* @example
|
|
892
|
+
* // Simple URL (format inferred)
|
|
893
|
+
* parseSeedConfig('https://example.com/data.csv')
|
|
894
|
+
* // { url: 'https://example.com/data.csv', format: 'csv' }
|
|
895
|
+
*
|
|
896
|
+
* @example
|
|
897
|
+
* // URL with TSV default
|
|
898
|
+
* parseSeedConfig('https://example.com/data')
|
|
899
|
+
* // { url: 'https://example.com/data', format: 'tsv' }
|
|
900
|
+
*
|
|
901
|
+
* @example
|
|
902
|
+
* // Object config with pagination
|
|
903
|
+
* parseSeedConfig({
|
|
904
|
+
* url: 'https://api.example.com/items',
|
|
905
|
+
* format: 'json',
|
|
906
|
+
* data: '$.items',
|
|
907
|
+
* next: '$.pagination.next'
|
|
908
|
+
* })
|
|
909
|
+
* // { url: '...', format: 'json', data: '$.items', next: '$.pagination.next' }
|
|
910
|
+
*/
|
|
911
|
+
export function parseSeedConfig(value) {
|
|
912
|
+
if (typeof value === 'string') {
|
|
913
|
+
// Infer format from URL extension
|
|
914
|
+
const url = value;
|
|
915
|
+
let format = 'tsv';
|
|
916
|
+
if (url.endsWith('.csv'))
|
|
917
|
+
format = 'csv';
|
|
918
|
+
else if (url.endsWith('.json'))
|
|
919
|
+
format = 'json';
|
|
920
|
+
return { url, format };
|
|
921
|
+
}
|
|
922
|
+
// Object config with explicit options
|
|
923
|
+
const config = {
|
|
924
|
+
url: value.url || value.$seed,
|
|
925
|
+
format: value.format || 'tsv',
|
|
926
|
+
};
|
|
927
|
+
if (value.idField)
|
|
928
|
+
config.idField = value.idField;
|
|
929
|
+
if (value.next)
|
|
930
|
+
config.next = value.next;
|
|
931
|
+
if (value.data)
|
|
932
|
+
config.data = value.data;
|
|
933
|
+
return config;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Converts string fields to prompt fields in a seeded type's field map.
|
|
937
|
+
*
|
|
938
|
+
* In seeded schemas, plain string values represent prompts for AI generation
|
|
939
|
+
* rather than simple field descriptions. This function performs the conversion
|
|
940
|
+
* after initial field parsing, ensuring that JSONPath fields and reference
|
|
941
|
+
* fields remain unchanged while string fields become prompt fields.
|
|
942
|
+
*
|
|
943
|
+
* @param fields - The parsed fields map from parseFieldType calls
|
|
944
|
+
* @returns A new fields map with string fields converted to prompt fields
|
|
945
|
+
*
|
|
946
|
+
* @example
|
|
947
|
+
* // Input: fields from a seeded type
|
|
948
|
+
* const fields = {
|
|
949
|
+
* title: { type: 'string', description: 'Some title' },
|
|
950
|
+
* jsonField: { type: 'jsonpath', path: '$.name' },
|
|
951
|
+
* ref: { type: 'reference', ref: 'Other', isArray: false },
|
|
952
|
+
* }
|
|
953
|
+
*
|
|
954
|
+
* // Output: string -> prompt, others unchanged
|
|
955
|
+
* const converted = convertStringsToPromptsInSeededType(fields)
|
|
956
|
+
* // converted.title = { type: 'prompt', prompt: 'Some title' }
|
|
957
|
+
* // converted.jsonField = { type: 'jsonpath', path: '$.name' }
|
|
958
|
+
* // converted.ref = { type: 'reference', ref: 'Other', isArray: false }
|
|
959
|
+
*
|
|
960
|
+
* @remarks
|
|
961
|
+
* This function is called during parseSchema when a type has `$seed` defined.
|
|
962
|
+
* Only StringField types are converted; all other field types pass through unchanged.
|
|
963
|
+
*/
|
|
964
|
+
export function convertStringsToPromptsInSeededType(fields) {
|
|
965
|
+
const result = {};
|
|
966
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
967
|
+
if (isStringField(field)) {
|
|
968
|
+
result[fieldName] = {
|
|
969
|
+
type: 'prompt',
|
|
970
|
+
prompt: field.description,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
result[fieldName] = field;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Parse an entire schema definition into a structured ParsedSchema.
|
|
981
|
+
*
|
|
982
|
+
* This is the main parsing function that takes a schema definition object and
|
|
983
|
+
* produces a fully parsed representation with all types, fields, references,
|
|
984
|
+
* and metadata resolved.
|
|
985
|
+
*
|
|
986
|
+
* @param schema - The schema definition object containing type definitions and metadata.
|
|
987
|
+
* Metadata fields are prefixed with `$` (e.g., `$id`, `$context`, `$settings`).
|
|
988
|
+
* All other keys are treated as type definitions.
|
|
989
|
+
*
|
|
990
|
+
* @returns A ParsedSchema object containing:
|
|
991
|
+
* - `types` - Record of parsed type definitions with fields and references
|
|
992
|
+
* - `hash` - Deterministic hash of the schema for versioning
|
|
993
|
+
* - `settings` - Optional schema-level settings (e.g., iconLibrary)
|
|
994
|
+
*
|
|
995
|
+
* @example
|
|
996
|
+
* const schema = parseSchema({
|
|
997
|
+
* $id: 'https://db.sb/blog',
|
|
998
|
+
* Post: {
|
|
999
|
+
* title: 'Post title',
|
|
1000
|
+
* content: 'Main content',
|
|
1001
|
+
* author: '->User',
|
|
1002
|
+
* tags: ['Tag labels']
|
|
1003
|
+
* },
|
|
1004
|
+
* User: {
|
|
1005
|
+
* name: 'User name',
|
|
1006
|
+
* email: 'Email address',
|
|
1007
|
+
* posts: '->Post[]'
|
|
1008
|
+
* }
|
|
1009
|
+
* })
|
|
1010
|
+
*
|
|
1011
|
+
* // schema.types.Post.fields.title
|
|
1012
|
+
* // { type: 'string', description: 'Post title' }
|
|
1013
|
+
*
|
|
1014
|
+
* // schema.types.Post.references
|
|
1015
|
+
* // [{ field: 'author', ref: 'User', isArray: false }]
|
|
1016
|
+
*
|
|
1017
|
+
* @example
|
|
1018
|
+
* // Schema with seed configuration and icons
|
|
1019
|
+
* const schema = parseSchema({
|
|
1020
|
+
* $context: 'startups',
|
|
1021
|
+
* $settings: { iconLibrary: 'lucide' },
|
|
1022
|
+
* Company: {
|
|
1023
|
+
* $icon: 'building',
|
|
1024
|
+
* $seed: 'https://api.example.com/companies.json',
|
|
1025
|
+
* name: 'Company name',
|
|
1026
|
+
* website: 'Website URL'
|
|
1027
|
+
* }
|
|
1028
|
+
* })
|
|
1029
|
+
*
|
|
1030
|
+
* // schema.types.Company.icon === 'building'
|
|
1031
|
+
* // schema.types.Company.seed.url === 'https://api.example.com/companies.json'
|
|
1032
|
+
*/
|
|
1033
|
+
export function parseSchema(schema) {
|
|
1034
|
+
const types = {};
|
|
1035
|
+
// Extract schema-level $settings
|
|
1036
|
+
let settings;
|
|
1037
|
+
const settingsValue = schema.$settings;
|
|
1038
|
+
if (settingsValue && typeof settingsValue === 'object' && settingsValue !== null) {
|
|
1039
|
+
const settingsObj = settingsValue;
|
|
1040
|
+
if (settingsObj.iconLibrary || Object.keys(settingsObj).length > 0) {
|
|
1041
|
+
settings = {};
|
|
1042
|
+
if (typeof settingsObj.iconLibrary === 'string') {
|
|
1043
|
+
settings.iconLibrary = settingsObj.iconLibrary;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// First pass: parse all types
|
|
1048
|
+
for (const [typeName, fields] of Object.entries(schema)) {
|
|
1049
|
+
// Skip metadata fields ($ prefixed)
|
|
1050
|
+
if (typeName.startsWith('$'))
|
|
1051
|
+
continue;
|
|
1052
|
+
// Skip if not an object (metadata string values)
|
|
1053
|
+
if (typeof fields !== 'object' || fields === null)
|
|
1054
|
+
continue;
|
|
1055
|
+
const parsedFields = {};
|
|
1056
|
+
const references = [];
|
|
1057
|
+
let seed;
|
|
1058
|
+
let icon;
|
|
1059
|
+
let group;
|
|
1060
|
+
let seedNext;
|
|
1061
|
+
let seedData;
|
|
1062
|
+
let seedId;
|
|
1063
|
+
let seedIdTransform;
|
|
1064
|
+
// Entity lifecycle handlers
|
|
1065
|
+
const entityHandlers = {};
|
|
1066
|
+
// State machine configuration
|
|
1067
|
+
let parsedState;
|
|
1068
|
+
// Data source configurations
|
|
1069
|
+
let source;
|
|
1070
|
+
let input;
|
|
1071
|
+
let from;
|
|
1072
|
+
let cache;
|
|
1073
|
+
// Generative format configurations
|
|
1074
|
+
let image;
|
|
1075
|
+
let speech;
|
|
1076
|
+
let diagram;
|
|
1077
|
+
let conventions;
|
|
1078
|
+
let code;
|
|
1079
|
+
// Entity-level eval and experiment
|
|
1080
|
+
let entityEval;
|
|
1081
|
+
let entityExperiment;
|
|
1082
|
+
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
|
1083
|
+
// Handle $seed metadata
|
|
1084
|
+
if (fieldName === '$seed') {
|
|
1085
|
+
seed = parseSeedConfig(fieldValue);
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
// Handle $next metadata (JSONPath to next page URL)
|
|
1089
|
+
if (fieldName === '$next') {
|
|
1090
|
+
if (typeof fieldValue === 'string') {
|
|
1091
|
+
seedNext = fieldValue;
|
|
1092
|
+
}
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
// Handle $data metadata (JSONPath to data array)
|
|
1096
|
+
if (fieldName === '$data') {
|
|
1097
|
+
if (typeof fieldValue === 'string') {
|
|
1098
|
+
seedData = fieldValue;
|
|
1099
|
+
}
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
// Handle $id metadata (JSONPath or transform for ID field)
|
|
1103
|
+
// Supports: '$.taskID', 'PascalCase($.example)', etc.
|
|
1104
|
+
if (fieldName === '$id') {
|
|
1105
|
+
if (typeof fieldValue === 'string') {
|
|
1106
|
+
const idConfig = parseIdConfig(fieldValue);
|
|
1107
|
+
seedId = idConfig.path;
|
|
1108
|
+
seedIdTransform = idConfig.transform;
|
|
1109
|
+
}
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
// Handle $icon metadata
|
|
1113
|
+
if (fieldName === '$icon') {
|
|
1114
|
+
if (typeof fieldValue === 'string') {
|
|
1115
|
+
icon = fieldValue;
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
// Handle $group metadata
|
|
1120
|
+
if (fieldName === '$group') {
|
|
1121
|
+
if (typeof fieldValue === 'string') {
|
|
1122
|
+
group = fieldValue;
|
|
1123
|
+
}
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
// Handle entity lifecycle handlers ($created, $updated, $deleted)
|
|
1127
|
+
if (fieldName === '$created' || fieldName === '$updated' || fieldName === '$deleted') {
|
|
1128
|
+
if (typeof fieldValue === 'function') {
|
|
1129
|
+
entityHandlers[fieldName] = serializeFunction(fieldName, fieldValue);
|
|
1130
|
+
}
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
// Handle $state (state machine definition)
|
|
1134
|
+
if (fieldName === '$state') {
|
|
1135
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && '$initial' in fieldValue) {
|
|
1136
|
+
parsedState = parseStateConfig(fieldValue);
|
|
1137
|
+
}
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
// Handle $source (external API source configuration)
|
|
1141
|
+
if (fieldName === '$source') {
|
|
1142
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1143
|
+
source = fieldValue;
|
|
1144
|
+
}
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
// Handle $input (input parameters for source)
|
|
1148
|
+
if (fieldName === '$input') {
|
|
1149
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1150
|
+
input = fieldValue;
|
|
1151
|
+
}
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
// Handle $from (multi-source aggregation)
|
|
1155
|
+
if (fieldName === '$from') {
|
|
1156
|
+
if (Array.isArray(fieldValue)) {
|
|
1157
|
+
from = fieldValue;
|
|
1158
|
+
}
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
// Handle $cache (cache duration)
|
|
1162
|
+
if (fieldName === '$cache') {
|
|
1163
|
+
if (typeof fieldValue === 'string') {
|
|
1164
|
+
cache = fieldValue;
|
|
1165
|
+
}
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
// Handle $image (image generation configuration)
|
|
1169
|
+
if (fieldName === '$image') {
|
|
1170
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1171
|
+
image = fieldValue;
|
|
1172
|
+
}
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
// Handle $speech (speech/TTS generation configuration)
|
|
1176
|
+
if (fieldName === '$speech') {
|
|
1177
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1178
|
+
speech = fieldValue;
|
|
1179
|
+
}
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
// Handle $diagram (diagram generation configuration)
|
|
1183
|
+
if (fieldName === '$diagram') {
|
|
1184
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1185
|
+
diagram = fieldValue;
|
|
1186
|
+
}
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
// Handle $code (code generation configuration)
|
|
1190
|
+
if (fieldName === '$code') {
|
|
1191
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1192
|
+
code = fieldValue;
|
|
1193
|
+
}
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
// Handle $conventions (naming conventions)
|
|
1197
|
+
if (fieldName === '$conventions') {
|
|
1198
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1199
|
+
conventions = fieldValue;
|
|
1200
|
+
}
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
// Handle $eval (entity-level evaluation definitions)
|
|
1204
|
+
if (fieldName === '$eval') {
|
|
1205
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1206
|
+
entityEval = fieldValue;
|
|
1207
|
+
}
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
// Handle $experiment (entity-level experiment definition)
|
|
1211
|
+
if (fieldName === '$experiment') {
|
|
1212
|
+
if (typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)) {
|
|
1213
|
+
entityExperiment = fieldValue;
|
|
1214
|
+
}
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const parsed = parseFieldType(fieldValue);
|
|
1218
|
+
parsedFields[fieldName] = parsed;
|
|
1219
|
+
if (parsed.type === 'reference') {
|
|
1220
|
+
const refField = parsed;
|
|
1221
|
+
references.push({
|
|
1222
|
+
field: fieldName,
|
|
1223
|
+
ref: refField.ref || refField.refs?.[0] || '',
|
|
1224
|
+
refs: refField.refs,
|
|
1225
|
+
isArray: refField.isArray,
|
|
1226
|
+
optional: refField.optional,
|
|
1227
|
+
fuzzy: refField.fuzzy,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
else if (parsed.type === 'array') {
|
|
1231
|
+
const arrField = parsed;
|
|
1232
|
+
// Track inline references from arrays
|
|
1233
|
+
if (arrField.refs) {
|
|
1234
|
+
for (const ref of arrField.refs) {
|
|
1235
|
+
references.push({
|
|
1236
|
+
field: fieldName,
|
|
1237
|
+
ref,
|
|
1238
|
+
isArray: true,
|
|
1239
|
+
optional: true, // Inline refs are always optional
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Track back references (for schema completeness, but these are read-only)
|
|
1244
|
+
if (arrField.backRef) {
|
|
1245
|
+
references.push({
|
|
1246
|
+
field: fieldName,
|
|
1247
|
+
ref: arrField.backRef,
|
|
1248
|
+
isArray: true,
|
|
1249
|
+
optional: true, // Back refs are collections
|
|
1250
|
+
backRef: true, // Mark as back reference - should not trigger generation
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// Merge $next, $data, $id into seed config if present
|
|
1256
|
+
if (seed) {
|
|
1257
|
+
if (seedNext)
|
|
1258
|
+
seed.next = seedNext;
|
|
1259
|
+
if (seedData)
|
|
1260
|
+
seed.data = seedData;
|
|
1261
|
+
if (seedId)
|
|
1262
|
+
seed.id = seedId;
|
|
1263
|
+
if (seedIdTransform)
|
|
1264
|
+
seed.idTransform = seedIdTransform;
|
|
1265
|
+
}
|
|
1266
|
+
// Post-process fields for seeded types: convert string fields to prompt fields
|
|
1267
|
+
// In seeded schemas, plain strings are prompts for AI generation, not string descriptions
|
|
1268
|
+
if (seed) {
|
|
1269
|
+
for (const [fieldName, field] of Object.entries(parsedFields)) {
|
|
1270
|
+
if (field.type === 'string') {
|
|
1271
|
+
const stringField = field;
|
|
1272
|
+
parsedFields[fieldName] = {
|
|
1273
|
+
type: 'prompt',
|
|
1274
|
+
prompt: stringField.description,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Determine if this is a source function type (provider:Type pattern)
|
|
1280
|
+
const isSourceFunction = typeName.includes(':') && !!source;
|
|
1281
|
+
// Determine if this is a generative type
|
|
1282
|
+
const isGenerative = !!(image || speech || diagram || code);
|
|
1283
|
+
types[typeName] = {
|
|
1284
|
+
name: typeName,
|
|
1285
|
+
fields: parsedFields,
|
|
1286
|
+
references,
|
|
1287
|
+
...(seed && { seed }),
|
|
1288
|
+
...(icon && { icon }),
|
|
1289
|
+
...(group && { group }),
|
|
1290
|
+
// Direct lifecycle handler access
|
|
1291
|
+
...(entityHandlers['$created'] && { $created: entityHandlers['$created'] }),
|
|
1292
|
+
...(entityHandlers['$updated'] && { $updated: entityHandlers['$updated'] }),
|
|
1293
|
+
...(entityHandlers['$deleted'] && { $deleted: entityHandlers['$deleted'] }),
|
|
1294
|
+
// Grouped handlers (deprecated)
|
|
1295
|
+
...(Object.keys(entityHandlers).length > 0 && { handlers: entityHandlers }),
|
|
1296
|
+
// State machine configuration
|
|
1297
|
+
...(parsedState && { $state: parsedState }),
|
|
1298
|
+
// Data source configurations
|
|
1299
|
+
...(source && { source }),
|
|
1300
|
+
...(input && { input }),
|
|
1301
|
+
...(from && { from }),
|
|
1302
|
+
...(cache && { cache }),
|
|
1303
|
+
...(isSourceFunction && { isSourceFunction }),
|
|
1304
|
+
// Generative format configurations
|
|
1305
|
+
...(image && { image }),
|
|
1306
|
+
...(speech && { speech }),
|
|
1307
|
+
...(diagram && { diagram }),
|
|
1308
|
+
...(code && { code }),
|
|
1309
|
+
...(conventions && { conventions }),
|
|
1310
|
+
...(isGenerative && { isGenerative }),
|
|
1311
|
+
// Entity-level eval and experiment
|
|
1312
|
+
...(entityEval && { eval: entityEval }),
|
|
1313
|
+
...(entityExperiment && { experiment: entityExperiment }),
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
// Second pass: validate references (warn but don't crash on undefined types)
|
|
1317
|
+
for (const type of Object.values(types)) {
|
|
1318
|
+
for (const ref of type.references) {
|
|
1319
|
+
// Check all refs in union types
|
|
1320
|
+
const refsToCheck = ref.refs || [ref.ref];
|
|
1321
|
+
for (const refType of refsToCheck) {
|
|
1322
|
+
if (refType && !types[refType]) {
|
|
1323
|
+
// Warn instead of throwing - allows app to load with schema errors
|
|
1324
|
+
console.warn(`Reference to undefined type: ${refType} (in type ${type.name})`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
// Also validate jsonpath-embedded fields
|
|
1329
|
+
for (const [fieldName, field] of Object.entries(type.fields)) {
|
|
1330
|
+
if (field.type === 'jsonpath-embedded') {
|
|
1331
|
+
const embeddedType = field.embeddedType;
|
|
1332
|
+
if (!types[embeddedType]) {
|
|
1333
|
+
console.warn(`JSONPath embedded reference to undefined type: ${embeddedType} (in type ${type.name}.${fieldName})`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
// Third pass: detect embedded types (string fields that exactly match a type name)
|
|
1339
|
+
for (const type of Object.values(types)) {
|
|
1340
|
+
for (const [fieldName, field] of Object.entries(type.fields)) {
|
|
1341
|
+
if (field.type === 'string') {
|
|
1342
|
+
const desc = field.description;
|
|
1343
|
+
// Check for embedded type pattern: TypeName, TypeName?, TypeName[]
|
|
1344
|
+
// Only match if the entire string is a type name (possibly with ? or [] suffix)
|
|
1345
|
+
const embeddedMatch = desc.match(/^(\w+)(\[\])?(\?)?$/);
|
|
1346
|
+
if (embeddedMatch) {
|
|
1347
|
+
const [, typeName, arrayMarker, optionalMarker] = embeddedMatch;
|
|
1348
|
+
// Check if the type name exists in our schema
|
|
1349
|
+
if (types[typeName]) {
|
|
1350
|
+
// Convert to embedded field
|
|
1351
|
+
const embeddedField = {
|
|
1352
|
+
type: 'embedded',
|
|
1353
|
+
embeddedType: typeName,
|
|
1354
|
+
isArray: !!arrayMarker,
|
|
1355
|
+
optional: !!optionalMarker,
|
|
1356
|
+
};
|
|
1357
|
+
type.fields[fieldName] = embeddedField;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// Parse schema-level directives
|
|
1364
|
+
let fn;
|
|
1365
|
+
let on;
|
|
1366
|
+
let evalDefs;
|
|
1367
|
+
let experiment;
|
|
1368
|
+
let analytics;
|
|
1369
|
+
const schemaObj = schema;
|
|
1370
|
+
// Parse $fn - schema-level functions
|
|
1371
|
+
if (schemaObj.$fn && typeof schemaObj.$fn === 'object') {
|
|
1372
|
+
fn = {};
|
|
1373
|
+
for (const [name, func] of Object.entries(schemaObj.$fn)) {
|
|
1374
|
+
if (typeof func === 'function') {
|
|
1375
|
+
fn[name] = serializeFunction(name, func);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
// Parse $on - schema-level event handlers
|
|
1380
|
+
if (schemaObj.$on && typeof schemaObj.$on === 'object') {
|
|
1381
|
+
on = {};
|
|
1382
|
+
for (const [name, func] of Object.entries(schemaObj.$on)) {
|
|
1383
|
+
if (typeof func === 'function') {
|
|
1384
|
+
on[name] = serializeFunction(name, func);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// Parse $eval - evaluation definitions
|
|
1389
|
+
if (schemaObj.$eval && typeof schemaObj.$eval === 'object') {
|
|
1390
|
+
evalDefs = schemaObj.$eval;
|
|
1391
|
+
}
|
|
1392
|
+
// Parse $experiment - experiment definitions
|
|
1393
|
+
if (schemaObj.$experiment && typeof schemaObj.$experiment === 'object') {
|
|
1394
|
+
experiment = schemaObj.$experiment;
|
|
1395
|
+
}
|
|
1396
|
+
// Parse $analytics - analytics configuration
|
|
1397
|
+
if (schemaObj.$analytics && typeof schemaObj.$analytics === 'object') {
|
|
1398
|
+
analytics = schemaObj.$analytics;
|
|
1399
|
+
}
|
|
1400
|
+
// Parse schema-level lifecycle handlers ($seeded, $ready, $error)
|
|
1401
|
+
const schemaHandlers = {};
|
|
1402
|
+
for (const key of ['$seeded', '$ready', '$error']) {
|
|
1403
|
+
if (typeof schemaObj[key] === 'function') {
|
|
1404
|
+
schemaHandlers[key] = serializeFunction(key, schemaObj[key]);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return {
|
|
1408
|
+
types,
|
|
1409
|
+
hash: schemaHash(schema),
|
|
1410
|
+
...(settings && { settings }),
|
|
1411
|
+
...(fn && { fn }),
|
|
1412
|
+
...(on && { on }),
|
|
1413
|
+
...(evalDefs && { eval: evalDefs }),
|
|
1414
|
+
...(experiment && { experiment }),
|
|
1415
|
+
...(analytics && { analytics }),
|
|
1416
|
+
...(Object.keys(schemaHandlers).length > 0 && { handlers: schemaHandlers }),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Convert a parsed type definition to a JSON Schema representation.
|
|
1421
|
+
*
|
|
1422
|
+
* Transforms the internal ParsedType format into a standard JSON Schema object
|
|
1423
|
+
* that can be used for validation, documentation, or AI generation prompts.
|
|
1424
|
+
*
|
|
1425
|
+
* @param parsedType - The parsed type definition to convert
|
|
1426
|
+
* @param parsedSchema - Optional full schema for resolving embedded types.
|
|
1427
|
+
* When provided, embedded types (e.g., `Address` in `address: 'Address'`) are
|
|
1428
|
+
* recursively expanded into their full JSON Schema representation.
|
|
1429
|
+
* @param seen - Internal set for tracking visited types to prevent infinite recursion
|
|
1430
|
+
* in circular reference scenarios. Do not pass this parameter manually.
|
|
1431
|
+
*
|
|
1432
|
+
* @returns A JSON Schema object with:
|
|
1433
|
+
* - `type: 'object'` - Always an object schema
|
|
1434
|
+
* - `properties` - Map of field names to their JSON Schema definitions
|
|
1435
|
+
* - `required` - Array of required field names (non-optional fields)
|
|
1436
|
+
* - `additionalProperties: false` - Strict schema, no extra fields allowed
|
|
1437
|
+
*
|
|
1438
|
+
* @example
|
|
1439
|
+
* const parsed = parseSchema({
|
|
1440
|
+
* $context: 'blog',
|
|
1441
|
+
* Post: {
|
|
1442
|
+
* title: 'Post title',
|
|
1443
|
+
* author: '->User',
|
|
1444
|
+
* tags: ['List 3-5 keywords']
|
|
1445
|
+
* }
|
|
1446
|
+
* })
|
|
1447
|
+
*
|
|
1448
|
+
* const jsonSchema = toJSONSchema(parsed.types.Post, parsed)
|
|
1449
|
+
* // {
|
|
1450
|
+
* // type: 'object',
|
|
1451
|
+
* // properties: {
|
|
1452
|
+
* // title: { type: 'string', description: 'Post title' },
|
|
1453
|
+
* // author: { type: 'string', description: 'Reference to User (ID)' },
|
|
1454
|
+
* // tags: { type: 'array', items: { type: 'string' }, minItems: 3, maxItems: 5 }
|
|
1455
|
+
* // },
|
|
1456
|
+
* // required: ['title', 'author', 'tags'],
|
|
1457
|
+
* // additionalProperties: false
|
|
1458
|
+
* // }
|
|
1459
|
+
*
|
|
1460
|
+
* @example
|
|
1461
|
+
* // With embedded types expanded
|
|
1462
|
+
* const schema = parseSchema({
|
|
1463
|
+
* $context: 'app',
|
|
1464
|
+
* User: {
|
|
1465
|
+
* name: 'User name',
|
|
1466
|
+
* address: 'Address' // Embedded type
|
|
1467
|
+
* },
|
|
1468
|
+
* Address: {
|
|
1469
|
+
* street: 'Street',
|
|
1470
|
+
* city: 'City'
|
|
1471
|
+
* }
|
|
1472
|
+
* })
|
|
1473
|
+
*
|
|
1474
|
+
* const jsonSchema = toJSONSchema(schema.types.User, schema)
|
|
1475
|
+
* // properties.address will contain the full Address schema inline
|
|
1476
|
+
*/
|
|
1477
|
+
export function toJSONSchema(parsedType, parsedSchemaOrIncludeRefs, seen = new Set()) {
|
|
1478
|
+
// Support both old API (boolean for includeReferences) and new API (ParsedSchema)
|
|
1479
|
+
const parsedSchema = typeof parsedSchemaOrIncludeRefs === 'object' ? parsedSchemaOrIncludeRefs : undefined;
|
|
1480
|
+
const includeReferences = typeof parsedSchemaOrIncludeRefs === 'boolean' ? parsedSchemaOrIncludeRefs : true;
|
|
1481
|
+
const properties = {};
|
|
1482
|
+
const required = [];
|
|
1483
|
+
for (const [fieldName, field] of Object.entries(parsedType.fields)) {
|
|
1484
|
+
// Skip computed fields - they're derived, not generated
|
|
1485
|
+
if (field.type === 'computed') {
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
const isOptional = 'optional' in field && field.optional;
|
|
1489
|
+
if (field.type === 'string') {
|
|
1490
|
+
properties[fieldName] = {
|
|
1491
|
+
type: 'string',
|
|
1492
|
+
description: field.description,
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
else if (field.type === 'reference' && includeReferences) {
|
|
1496
|
+
const refName = field.refs ? field.refs.join(' | ') : field.ref;
|
|
1497
|
+
if (field.isArray) {
|
|
1498
|
+
properties[fieldName] = {
|
|
1499
|
+
type: 'array',
|
|
1500
|
+
items: { type: 'string' },
|
|
1501
|
+
description: `Array of ${refName} references (IDs)`,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
properties[fieldName] = {
|
|
1506
|
+
type: 'string',
|
|
1507
|
+
description: `Reference to ${refName} (ID)`,
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
else if (field.type === 'array') {
|
|
1512
|
+
const arrayProp = {
|
|
1513
|
+
type: 'array',
|
|
1514
|
+
items: { type: 'string' },
|
|
1515
|
+
description: field.description,
|
|
1516
|
+
};
|
|
1517
|
+
// Wire up minItems/maxItems from parsed count
|
|
1518
|
+
if (field.count !== undefined) {
|
|
1519
|
+
if (typeof field.count === 'number') {
|
|
1520
|
+
arrayProp.minItems = field.count;
|
|
1521
|
+
arrayProp.maxItems = field.count;
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
arrayProp.minItems = field.count.min;
|
|
1525
|
+
arrayProp.maxItems = field.count.max;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
properties[fieldName] = arrayProp;
|
|
1529
|
+
}
|
|
1530
|
+
else if (field.type === 'object') {
|
|
1531
|
+
const nestedProps = {};
|
|
1532
|
+
const nestedRequired = [];
|
|
1533
|
+
for (const [propName, propField] of Object.entries(field.properties)) {
|
|
1534
|
+
nestedProps[propName] = {
|
|
1535
|
+
type: 'string',
|
|
1536
|
+
description: propField.description,
|
|
1537
|
+
};
|
|
1538
|
+
nestedRequired.push(propName);
|
|
1539
|
+
}
|
|
1540
|
+
properties[fieldName] = {
|
|
1541
|
+
type: 'object',
|
|
1542
|
+
properties: nestedProps,
|
|
1543
|
+
required: nestedRequired,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
else if (field.type === 'embedded' && parsedSchema) {
|
|
1547
|
+
// Recursively embed the referenced type's JSON schema
|
|
1548
|
+
const embeddedTypeName = field.embeddedType;
|
|
1549
|
+
const embeddedType = parsedSchema.types[embeddedTypeName];
|
|
1550
|
+
if (embeddedType) {
|
|
1551
|
+
// Determine if we're in a circular reference situation
|
|
1552
|
+
const isCircular = seen.has(embeddedTypeName);
|
|
1553
|
+
// Get the embedded type's JSON schema
|
|
1554
|
+
// For circular refs, stop recursion by not passing parsedSchema
|
|
1555
|
+
// For normal refs, continue with updated seen set
|
|
1556
|
+
const newSeen = isCircular ? seen : new Set([...seen, embeddedTypeName]);
|
|
1557
|
+
const embeddedSchema = toJSONSchema(embeddedType, isCircular ? undefined : parsedSchema, newSeen);
|
|
1558
|
+
// Build the embedded object schema
|
|
1559
|
+
const embeddedObjectSchema = {
|
|
1560
|
+
type: 'object',
|
|
1561
|
+
properties: embeddedSchema.properties,
|
|
1562
|
+
required: embeddedSchema.required,
|
|
1563
|
+
additionalProperties: false,
|
|
1564
|
+
};
|
|
1565
|
+
if (field.isArray) {
|
|
1566
|
+
properties[fieldName] = {
|
|
1567
|
+
type: 'array',
|
|
1568
|
+
items: embeddedObjectSchema,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
properties[fieldName] = embeddedObjectSchema;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
else if (field.type === 'embedded') {
|
|
1577
|
+
// If no parsedSchema provided, fall back to string representation
|
|
1578
|
+
properties[fieldName] = {
|
|
1579
|
+
type: 'string',
|
|
1580
|
+
description: field.embeddedType,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
// Only add to required if:
|
|
1584
|
+
// - not optional AND
|
|
1585
|
+
// - (not a reference OR includeReferences is true)
|
|
1586
|
+
const isReference = field.type === 'reference';
|
|
1587
|
+
if (!isOptional && (!isReference || includeReferences)) {
|
|
1588
|
+
required.push(fieldName);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
type: 'object',
|
|
1593
|
+
properties,
|
|
1594
|
+
required,
|
|
1595
|
+
additionalProperties: false,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Generate a deterministic hash for a schema definition.
|
|
1600
|
+
*
|
|
1601
|
+
* Creates a consistent 8-character hexadecimal hash that uniquely identifies
|
|
1602
|
+
* a schema's structure. The hash is computed using the FNV-1a algorithm on
|
|
1603
|
+
* the JSON-serialized schema with sorted keys, ensuring the same schema
|
|
1604
|
+
* always produces the same hash regardless of property order.
|
|
1605
|
+
*
|
|
1606
|
+
* @param schema - The schema definition to hash
|
|
1607
|
+
*
|
|
1608
|
+
* @returns An 8-character hexadecimal hash string
|
|
1609
|
+
*
|
|
1610
|
+
* @example
|
|
1611
|
+
* const hash1 = schemaHash({
|
|
1612
|
+
* $context: 'blog',
|
|
1613
|
+
* Post: { title: 'Title' }
|
|
1614
|
+
* })
|
|
1615
|
+
* // e.g., 'a1b2c3d4'
|
|
1616
|
+
*
|
|
1617
|
+
* // Same schema, different property order = same hash
|
|
1618
|
+
* const hash2 = schemaHash({
|
|
1619
|
+
* Post: { title: 'Title' },
|
|
1620
|
+
* $context: 'blog'
|
|
1621
|
+
* })
|
|
1622
|
+
* // hash1 === hash2
|
|
1623
|
+
*
|
|
1624
|
+
* @example
|
|
1625
|
+
* // Used for schema versioning
|
|
1626
|
+
* const schema = parseSchema({ $context: 'app', User: { name: 'Name' } })
|
|
1627
|
+
* console.log(schema.hash)
|
|
1628
|
+
* // The hash can be used to detect schema changes and trigger migrations
|
|
1629
|
+
*/
|
|
1630
|
+
export function schemaHash(schema) {
|
|
1631
|
+
// Sort keys recursively for consistent hashing
|
|
1632
|
+
const sortedSchema = sortObject(schema);
|
|
1633
|
+
const json = JSON.stringify(sortedSchema);
|
|
1634
|
+
// Simple hash function (FNV-1a)
|
|
1635
|
+
let hash = 2166136261;
|
|
1636
|
+
for (let i = 0; i < json.length; i++) {
|
|
1637
|
+
hash ^= json.charCodeAt(i);
|
|
1638
|
+
hash = (hash * 16777619) >>> 0;
|
|
1639
|
+
}
|
|
1640
|
+
return hash.toString(16).padStart(8, '0');
|
|
1641
|
+
}
|
|
1642
|
+
function sortObject(obj) {
|
|
1643
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
1644
|
+
return obj;
|
|
1645
|
+
}
|
|
1646
|
+
if (Array.isArray(obj)) {
|
|
1647
|
+
return obj.map(sortObject);
|
|
1648
|
+
}
|
|
1649
|
+
const sorted = {};
|
|
1650
|
+
for (const key of Object.keys(obj).sort()) {
|
|
1651
|
+
sorted[key] = sortObject(obj[key]);
|
|
1652
|
+
}
|
|
1653
|
+
return sorted;
|
|
1654
|
+
}
|
|
1655
|
+
// ============================================================================
|
|
1656
|
+
// SDK Configuration
|
|
1657
|
+
// ============================================================================
|
|
1658
|
+
/**
|
|
1659
|
+
* Get the default API base URL. Reads environment variable dynamically
|
|
1660
|
+
* to support test mocking.
|
|
1661
|
+
*/
|
|
1662
|
+
function getDefaultApiBase() {
|
|
1663
|
+
return (typeof process !== 'undefined' && process.env?.DB4AI_API_BASE) || 'https://db4ai.dev';
|
|
1664
|
+
}
|
|
1665
|
+
function createJob() {
|
|
1666
|
+
return {
|
|
1667
|
+
id: `job_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`,
|
|
1668
|
+
status: 'queued',
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Internal class for managing entity collection state
|
|
1673
|
+
*/
|
|
1674
|
+
class EntityCollectionImpl {
|
|
1675
|
+
$type;
|
|
1676
|
+
_items = [];
|
|
1677
|
+
_baseUrl;
|
|
1678
|
+
_namespace;
|
|
1679
|
+
_registerSchema;
|
|
1680
|
+
constructor(typeName, baseUrl, namespace, registerSchema) {
|
|
1681
|
+
this.$type = typeName;
|
|
1682
|
+
this._baseUrl = baseUrl;
|
|
1683
|
+
this._namespace = namespace;
|
|
1684
|
+
this._registerSchema = registerSchema;
|
|
1685
|
+
}
|
|
1686
|
+
get $count() {
|
|
1687
|
+
return this._items.length;
|
|
1688
|
+
}
|
|
1689
|
+
_addItem(item) {
|
|
1690
|
+
this._items.push(item);
|
|
1691
|
+
}
|
|
1692
|
+
_setItems(items) {
|
|
1693
|
+
this._items = items;
|
|
1694
|
+
}
|
|
1695
|
+
_getItems() {
|
|
1696
|
+
return this._items;
|
|
1697
|
+
}
|
|
1698
|
+
[Symbol.iterator]() {
|
|
1699
|
+
return this._items[Symbol.iterator]();
|
|
1700
|
+
}
|
|
1701
|
+
forEach(fn) {
|
|
1702
|
+
const job = createJob();
|
|
1703
|
+
job.status = 'running';
|
|
1704
|
+
try {
|
|
1705
|
+
for (const item of this._items) {
|
|
1706
|
+
fn(item);
|
|
1707
|
+
}
|
|
1708
|
+
job.status = 'completed';
|
|
1709
|
+
}
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
job.status = 'failed';
|
|
1712
|
+
job.error = error instanceof Error ? error : new Error(String(error));
|
|
1713
|
+
}
|
|
1714
|
+
return job;
|
|
1715
|
+
}
|
|
1716
|
+
map(fn) {
|
|
1717
|
+
const items = this._items;
|
|
1718
|
+
return new Combinable(function* () {
|
|
1719
|
+
for (const item of items) {
|
|
1720
|
+
yield fn(item);
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
where(predicate) {
|
|
1725
|
+
const items = this._items;
|
|
1726
|
+
return new Combinable(function* () {
|
|
1727
|
+
for (const item of items) {
|
|
1728
|
+
if (predicate(item)) {
|
|
1729
|
+
yield item;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
async fetchAll() {
|
|
1735
|
+
await this._registerSchema();
|
|
1736
|
+
const url = `${this._baseUrl}/api/${this._namespace}/${this.$type}`;
|
|
1737
|
+
const res = await fetch(url);
|
|
1738
|
+
if (!res.ok) {
|
|
1739
|
+
throw new APIError('GENERATION_FAILED', `Failed to fetch ${this.$type}: ${await res.text()}`, res.status);
|
|
1740
|
+
}
|
|
1741
|
+
const data = await res.json();
|
|
1742
|
+
const items = Array.isArray(data) ? data : data.items || [];
|
|
1743
|
+
this._setItems(items);
|
|
1744
|
+
return items;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Create a callable EntityCollection using a Proxy.
|
|
1749
|
+
* The returned object is both a function (factory) and has collection methods/properties.
|
|
1750
|
+
*/
|
|
1751
|
+
function createEntityCollection(typeName, baseUrl, namespace, registerSchema, factory) {
|
|
1752
|
+
const collection = new EntityCollectionImpl(typeName, baseUrl, namespace, registerSchema);
|
|
1753
|
+
// Create a proxy around the factory function
|
|
1754
|
+
return new Proxy(factory, {
|
|
1755
|
+
get(target, prop) {
|
|
1756
|
+
// Handle Symbol.iterator for iteration
|
|
1757
|
+
if (prop === Symbol.iterator) {
|
|
1758
|
+
return () => collection[Symbol.iterator]();
|
|
1759
|
+
}
|
|
1760
|
+
// Return collection properties and methods
|
|
1761
|
+
if (prop === '$type')
|
|
1762
|
+
return collection.$type;
|
|
1763
|
+
if (prop === '$count')
|
|
1764
|
+
return collection.$count;
|
|
1765
|
+
if (prop === 'forEach')
|
|
1766
|
+
return collection.forEach.bind(collection);
|
|
1767
|
+
if (prop === 'map')
|
|
1768
|
+
return collection.map.bind(collection);
|
|
1769
|
+
if (prop === 'where')
|
|
1770
|
+
return collection.where.bind(collection);
|
|
1771
|
+
if (prop === 'fetchAll')
|
|
1772
|
+
return collection.fetchAll.bind(collection);
|
|
1773
|
+
// Internal methods for testing/manipulation
|
|
1774
|
+
if (prop === '_addItem')
|
|
1775
|
+
return collection._addItem.bind(collection);
|
|
1776
|
+
if (prop === '_setItems')
|
|
1777
|
+
return collection._setItems.bind(collection);
|
|
1778
|
+
if (prop === '_getItems')
|
|
1779
|
+
return collection._getItems.bind(collection);
|
|
1780
|
+
return undefined;
|
|
1781
|
+
},
|
|
1782
|
+
apply(target, thisArg, args) {
|
|
1783
|
+
// When called as a function, invoke the factory
|
|
1784
|
+
return factory(args[0], args[1]);
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Extract API base URL from $id
|
|
1790
|
+
* @example
|
|
1791
|
+
* getApiBaseFromId('https://db.sb/blogs') // 'https://db.sb'
|
|
1792
|
+
* getApiBaseFromId('https://startups.db.sb') // 'https://startups.db.sb'
|
|
1793
|
+
* getApiBaseFromId('my-namespace') // undefined (use default)
|
|
1794
|
+
*/
|
|
1795
|
+
function getApiBaseFromId(id) {
|
|
1796
|
+
// If it's not a URL, return undefined to use default
|
|
1797
|
+
if (!id.startsWith('http://') && !id.startsWith('https://')) {
|
|
1798
|
+
return undefined;
|
|
1799
|
+
}
|
|
1800
|
+
try {
|
|
1801
|
+
const url = new URL(id);
|
|
1802
|
+
return `${url.protocol}//${url.host}`;
|
|
1803
|
+
}
|
|
1804
|
+
catch {
|
|
1805
|
+
return undefined;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Parse namespace from $id - supports both full URLs and plain namespace strings
|
|
1810
|
+
* @example
|
|
1811
|
+
* parseIdNamespace('https://db.sb/blogs') // 'blogs'
|
|
1812
|
+
* parseIdNamespace('my-namespace') // 'my-namespace'
|
|
1813
|
+
* parseIdNamespace('https://db.sb/') // null
|
|
1814
|
+
*/
|
|
1815
|
+
function parseIdNamespace(id) {
|
|
1816
|
+
// If it's not a URL, treat the whole string as the namespace
|
|
1817
|
+
if (!id.startsWith('http://') && !id.startsWith('https://')) {
|
|
1818
|
+
const trimmed = id.trim();
|
|
1819
|
+
return trimmed || null;
|
|
1820
|
+
}
|
|
1821
|
+
// Otherwise parse as URL
|
|
1822
|
+
return parseNamespaceFromId(id);
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Resolve all references in a generated object recursively
|
|
1826
|
+
*/
|
|
1827
|
+
async function resolveReferences(obj, typeName, parsed, baseUrl, namespace, cache) {
|
|
1828
|
+
const type = parsed.types[typeName];
|
|
1829
|
+
if (!type)
|
|
1830
|
+
return obj;
|
|
1831
|
+
const resolved = { ...obj };
|
|
1832
|
+
for (const [fieldName, field] of Object.entries(type.fields)) {
|
|
1833
|
+
const value = obj[fieldName];
|
|
1834
|
+
if (value === undefined || value === null)
|
|
1835
|
+
continue;
|
|
1836
|
+
if (field.type === 'array' && field.refs && Array.isArray(value)) {
|
|
1837
|
+
// Inline references: ['... ->Type'] - resolve each ID to full object
|
|
1838
|
+
const refType = field.refs[0];
|
|
1839
|
+
const resolvedItems = await Promise.all(value.map(async (refId) => {
|
|
1840
|
+
const cacheKey = `${refType}:${refId}`;
|
|
1841
|
+
if (cache.has(cacheKey))
|
|
1842
|
+
return cache.get(cacheKey);
|
|
1843
|
+
// If refId is already a URL, use it directly; otherwise construct URL
|
|
1844
|
+
const refUrl = refId.startsWith('http://') || refId.startsWith('https://')
|
|
1845
|
+
? refId
|
|
1846
|
+
: `${baseUrl}/api/${namespace}/${refType}/${refId}`;
|
|
1847
|
+
const refRes = await fetch(refUrl);
|
|
1848
|
+
if (!refRes.ok)
|
|
1849
|
+
return { id: refId, _error: 'Failed to resolve' };
|
|
1850
|
+
const refObj = await refRes.json();
|
|
1851
|
+
cache.set(cacheKey, refObj);
|
|
1852
|
+
// Recursively resolve nested references
|
|
1853
|
+
return resolveReferences(refObj, refType, parsed, baseUrl, namespace, cache);
|
|
1854
|
+
}));
|
|
1855
|
+
resolved[fieldName] = resolvedItems;
|
|
1856
|
+
}
|
|
1857
|
+
else if (field.type === 'reference' && !field.isArray && typeof value === 'string') {
|
|
1858
|
+
// Single reference: ->Type - resolve to full object
|
|
1859
|
+
const refType = field.ref || field.refs?.[0];
|
|
1860
|
+
if (refType) {
|
|
1861
|
+
const cacheKey = `${refType}:${value}`;
|
|
1862
|
+
if (cache.has(cacheKey)) {
|
|
1863
|
+
resolved[fieldName] = cache.get(cacheKey);
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
// If value is already a URL, use it directly; otherwise construct URL
|
|
1867
|
+
const refUrl = value.startsWith('http://') || value.startsWith('https://')
|
|
1868
|
+
? value
|
|
1869
|
+
: `${baseUrl}/api/${namespace}/${refType}/${value}`;
|
|
1870
|
+
const refRes = await fetch(refUrl);
|
|
1871
|
+
if (refRes.ok) {
|
|
1872
|
+
const refObj = await refRes.json();
|
|
1873
|
+
cache.set(cacheKey, refObj);
|
|
1874
|
+
resolved[fieldName] = await resolveReferences(refObj, refType, parsed, baseUrl, namespace, cache);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
else if (field.type === 'reference' && field.isArray && Array.isArray(value)) {
|
|
1880
|
+
// Array reference: ->Type[] - resolve each ID to full object
|
|
1881
|
+
const refType = field.ref || field.refs?.[0];
|
|
1882
|
+
if (refType) {
|
|
1883
|
+
const resolvedItems = await Promise.all(value.map(async (refId) => {
|
|
1884
|
+
const cacheKey = `${refType}:${refId}`;
|
|
1885
|
+
if (cache.has(cacheKey))
|
|
1886
|
+
return cache.get(cacheKey);
|
|
1887
|
+
// If refId is already a URL, use it directly; otherwise construct URL
|
|
1888
|
+
const refUrl = refId.startsWith('http://') || refId.startsWith('https://')
|
|
1889
|
+
? refId
|
|
1890
|
+
: `${baseUrl}/api/${namespace}/${refType}/${refId}`;
|
|
1891
|
+
const refRes = await fetch(refUrl);
|
|
1892
|
+
if (!refRes.ok)
|
|
1893
|
+
return { id: refId, _error: 'Failed to resolve' };
|
|
1894
|
+
const refObj = await refRes.json();
|
|
1895
|
+
cache.set(cacheKey, refObj);
|
|
1896
|
+
return resolveReferences(refObj, refType, parsed, baseUrl, namespace, cache);
|
|
1897
|
+
}));
|
|
1898
|
+
resolved[fieldName] = resolvedItems;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// Back references (['<-Type']) are populated by the API, no client-side resolution needed
|
|
1902
|
+
}
|
|
1903
|
+
return resolved;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Create a database instance from a schema definition.
|
|
1907
|
+
*
|
|
1908
|
+
* This is the main entry point for defining and working with a db4.ai schema.
|
|
1909
|
+
* It parses the schema, registers it with the API, and returns a typed object
|
|
1910
|
+
* that provides access to all defined types as EntityCollections.
|
|
1911
|
+
*
|
|
1912
|
+
* ## API Base URL Configuration
|
|
1913
|
+
*
|
|
1914
|
+
* The SDK determines the API base URL in the following priority order:
|
|
1915
|
+
* 1. Host from `$id` if it's a full URL (e.g., `'https://custom.api.com/myapp'`)
|
|
1916
|
+
* 2. `DB4AI_API_BASE` environment variable
|
|
1917
|
+
* 3. Default: `'https://db4ai.dev'`
|
|
1918
|
+
*
|
|
1919
|
+
* @param schema - The schema definition object. Must include either:
|
|
1920
|
+
* - `$id` - A URL or namespace identifier (e.g., `'https://db.sb/blog'` or `'blog'`)
|
|
1921
|
+
* - `$context` - A namespace string (e.g., `'my-app'`)
|
|
1922
|
+
*
|
|
1923
|
+
* Type definitions are records where keys are type names and values define fields.
|
|
1924
|
+
*
|
|
1925
|
+
* @returns A DBResult object providing:
|
|
1926
|
+
* - `schema` - The original schema definition
|
|
1927
|
+
* - `types` - Parsed type definitions
|
|
1928
|
+
* - `hash` - Schema version hash
|
|
1929
|
+
* - `namespace` - The resolved namespace
|
|
1930
|
+
* - `baseUrl` - API base URL
|
|
1931
|
+
* - `toJSONSchema(typeName)` - Convert a type to JSON Schema
|
|
1932
|
+
* - `registerSchema()` - Manually register the schema with the API
|
|
1933
|
+
* - Dynamic type accessors (e.g., `db.Post`, `db.User`) as EntityCollections
|
|
1934
|
+
*
|
|
1935
|
+
* @throws Error if neither `$id` nor `$context` is provided
|
|
1936
|
+
*
|
|
1937
|
+
* @example
|
|
1938
|
+
* // Define a blog schema
|
|
1939
|
+
* const db = DB({
|
|
1940
|
+
* $context: 'blog',
|
|
1941
|
+
* Post: {
|
|
1942
|
+
* title: 'Post title',
|
|
1943
|
+
* content: 'Main content',
|
|
1944
|
+
* author: '->User',
|
|
1945
|
+
* tags: ['List 3-5 keywords']
|
|
1946
|
+
* },
|
|
1947
|
+
* User: {
|
|
1948
|
+
* name: 'User name',
|
|
1949
|
+
* email: 'Email address'
|
|
1950
|
+
* }
|
|
1951
|
+
* })
|
|
1952
|
+
*
|
|
1953
|
+
* @example
|
|
1954
|
+
* // Generate a new post
|
|
1955
|
+
* const post = await db.Post('my-first-post', { topic: 'AI' })
|
|
1956
|
+
* console.log(post.title, post.content)
|
|
1957
|
+
*
|
|
1958
|
+
* @example
|
|
1959
|
+
* // Fetch all posts and iterate
|
|
1960
|
+
* await db.Post.fetchAll()
|
|
1961
|
+
* for (const post of db.Post) {
|
|
1962
|
+
* console.log(post.title)
|
|
1963
|
+
* }
|
|
1964
|
+
*
|
|
1965
|
+
* @example
|
|
1966
|
+
* // Use with full URL
|
|
1967
|
+
* const db = DB({
|
|
1968
|
+
* $id: 'https://db.sb/startups',
|
|
1969
|
+
* Company: {
|
|
1970
|
+
* name: 'Company name',
|
|
1971
|
+
* description: 'Company description'
|
|
1972
|
+
* }
|
|
1973
|
+
* })
|
|
1974
|
+
*/
|
|
1975
|
+
export function DB(schema) {
|
|
1976
|
+
const parsed = parseSchema(schema);
|
|
1977
|
+
const metadata = schema;
|
|
1978
|
+
const schemaId = metadata.$id;
|
|
1979
|
+
const schemaContext = metadata.$context;
|
|
1980
|
+
// Either $id or $context is required
|
|
1981
|
+
if (!schemaId && !schemaContext) {
|
|
1982
|
+
throw new SchemaValidationError('MISSING_CONTEXT', 'Schema must have $id or $context set (e.g., $context: "my-app" or $id: "https://db4ai.dev/my-app")');
|
|
1983
|
+
}
|
|
1984
|
+
// Parse namespace from $id (URL or plain string) or use $context directly
|
|
1985
|
+
let namespace = null;
|
|
1986
|
+
let baseUrl = getDefaultApiBase();
|
|
1987
|
+
if (schemaContext) {
|
|
1988
|
+
// $context is always just a namespace, uses default base URL
|
|
1989
|
+
namespace = schemaContext.trim() || null;
|
|
1990
|
+
}
|
|
1991
|
+
if (schemaId) {
|
|
1992
|
+
// $id can be a full URL or plain namespace
|
|
1993
|
+
namespace = parseIdNamespace(schemaId);
|
|
1994
|
+
// Use $id's host if it's a URL (overrides default/env var)
|
|
1995
|
+
const idBaseUrl = getApiBaseFromId(schemaId);
|
|
1996
|
+
if (idBaseUrl) {
|
|
1997
|
+
baseUrl = idBaseUrl;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (!namespace) {
|
|
2001
|
+
throw new SchemaValidationError('MISSING_NAMESPACE', 'Schema must specify a namespace via $context or $id (e.g., $context: "my-app")');
|
|
2002
|
+
}
|
|
2003
|
+
const instances = [];
|
|
2004
|
+
// Track schema registration state
|
|
2005
|
+
let schemaRegistered = false;
|
|
2006
|
+
let schemaRegistrationPromise = null;
|
|
2007
|
+
// Register schema with API (idempotent, cached)
|
|
2008
|
+
async function registerSchema() {
|
|
2009
|
+
if (schemaRegistered)
|
|
2010
|
+
return;
|
|
2011
|
+
if (schemaRegistrationPromise)
|
|
2012
|
+
return schemaRegistrationPromise;
|
|
2013
|
+
schemaRegistrationPromise = (async () => {
|
|
2014
|
+
const res = await fetch(`${baseUrl}/api/${namespace}/schema`, {
|
|
2015
|
+
method: 'POST',
|
|
2016
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2017
|
+
body: JSON.stringify(schema),
|
|
2018
|
+
});
|
|
2019
|
+
if (!res.ok) {
|
|
2020
|
+
const error = await res.text();
|
|
2021
|
+
throw new APIError('REGISTRATION_FAILED', `Failed to register schema: ${error}`, res.status);
|
|
2022
|
+
}
|
|
2023
|
+
schemaRegistered = true;
|
|
2024
|
+
})();
|
|
2025
|
+
return schemaRegistrationPromise;
|
|
2026
|
+
}
|
|
2027
|
+
// Create base result object
|
|
2028
|
+
const base = {
|
|
2029
|
+
schema,
|
|
2030
|
+
types: parsed.types,
|
|
2031
|
+
hash: parsed.hash,
|
|
2032
|
+
id: schemaId,
|
|
2033
|
+
context: schemaContext,
|
|
2034
|
+
namespace,
|
|
2035
|
+
baseUrl,
|
|
2036
|
+
instances,
|
|
2037
|
+
registerSchema,
|
|
2038
|
+
toJSONSchema: (typeName) => {
|
|
2039
|
+
const type = parsed.types[typeName];
|
|
2040
|
+
if (!type)
|
|
2041
|
+
throw new SchemaValidationError('INVALID_FIELD_TYPE', `Unknown type: ${typeName}`);
|
|
2042
|
+
return toJSONSchema(type, parsed);
|
|
2043
|
+
},
|
|
2044
|
+
};
|
|
2045
|
+
// Cache EntityCollections so the same instance is returned each time
|
|
2046
|
+
const collectionCache = new Map();
|
|
2047
|
+
// Create a proxy that handles dynamic type access
|
|
2048
|
+
return new Proxy(base, {
|
|
2049
|
+
get(target, prop) {
|
|
2050
|
+
// Return known properties directly
|
|
2051
|
+
if (prop in target) {
|
|
2052
|
+
return target[prop];
|
|
2053
|
+
}
|
|
2054
|
+
// For type names, return an EntityCollection (cached)
|
|
2055
|
+
const typeName = String(prop);
|
|
2056
|
+
if (parsed.types[typeName]) {
|
|
2057
|
+
// Return cached collection if exists
|
|
2058
|
+
if (collectionCache.has(typeName)) {
|
|
2059
|
+
return collectionCache.get(typeName);
|
|
2060
|
+
}
|
|
2061
|
+
// Create factory function for this type
|
|
2062
|
+
const factory = async (id, context) => {
|
|
2063
|
+
// Ensure schema is registered
|
|
2064
|
+
await registerSchema();
|
|
2065
|
+
// Build URL with context as query params if provided
|
|
2066
|
+
let url = `${baseUrl}/api/${namespace}/${typeName}/${id}`;
|
|
2067
|
+
if (context && Object.keys(context).length > 0) {
|
|
2068
|
+
const params = new URLSearchParams();
|
|
2069
|
+
for (const [key, value] of Object.entries(context)) {
|
|
2070
|
+
if (value !== undefined && value !== null) {
|
|
2071
|
+
params.set(key, String(value));
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
url += `?${params.toString()}`;
|
|
2075
|
+
}
|
|
2076
|
+
// Fetch/generate the object with extended timeout for long-running generations
|
|
2077
|
+
// Default Node.js undici timeout is 300s, but Blog generation can take 80-150s
|
|
2078
|
+
// We use 10 minutes to handle complex hierarchies with AI latency variance
|
|
2079
|
+
const controller = new AbortController();
|
|
2080
|
+
const timeoutId = setTimeout(() => controller.abort(), 600000); // 10 minute timeout
|
|
2081
|
+
try {
|
|
2082
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
2083
|
+
clearTimeout(timeoutId);
|
|
2084
|
+
if (!res.ok) {
|
|
2085
|
+
const error = await res.text();
|
|
2086
|
+
throw new APIError('GENERATION_FAILED', `Failed to generate ${typeName}/${id}: ${error}`, res.status);
|
|
2087
|
+
}
|
|
2088
|
+
const obj = await res.json();
|
|
2089
|
+
// Resolve all references recursively
|
|
2090
|
+
const cache = new Map();
|
|
2091
|
+
return resolveReferences(obj, typeName, parsed, baseUrl, namespace, cache);
|
|
2092
|
+
}
|
|
2093
|
+
finally {
|
|
2094
|
+
clearTimeout(timeoutId);
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
// Create and cache the EntityCollection
|
|
2098
|
+
const collection = createEntityCollection(typeName, baseUrl, namespace, registerSchema, factory);
|
|
2099
|
+
collectionCache.set(typeName, collection);
|
|
2100
|
+
return collection;
|
|
2101
|
+
}
|
|
2102
|
+
return undefined;
|
|
2103
|
+
},
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
// ============================================================================
|
|
2107
|
+
// Combinable: Lazy cartesian product pipeline builder
|
|
2108
|
+
// ============================================================================
|
|
2109
|
+
/**
|
|
2110
|
+
* A lazy pipeline builder for cartesian products and collection transformations.
|
|
2111
|
+
*
|
|
2112
|
+
* Combinable provides a fluent API for building transformation pipelines where
|
|
2113
|
+
* no computation happens until a terminal operation is invoked. This enables
|
|
2114
|
+
* efficient processing of large datasets and cartesian products.
|
|
2115
|
+
*
|
|
2116
|
+
* Key features:
|
|
2117
|
+
* - Lazy evaluation: transformations are only applied when results are consumed
|
|
2118
|
+
* - Chainable: all intermediate operations return new Combinable instances
|
|
2119
|
+
* - Iterable: works with for...of loops and spread operator
|
|
2120
|
+
*
|
|
2121
|
+
* Intermediate operations (return Combinable):
|
|
2122
|
+
* - `where(predicate)` - Filter items by predicate
|
|
2123
|
+
* - `map(fn)` - Transform each item
|
|
2124
|
+
* - `take(n)` - Limit to first n items
|
|
2125
|
+
* - `skip(n)` - Skip first n items
|
|
2126
|
+
*
|
|
2127
|
+
* Terminal operations (trigger evaluation):
|
|
2128
|
+
* - `all()` - Collect all results into an array
|
|
2129
|
+
* - `first()` - Get the first result or undefined
|
|
2130
|
+
* - `count()` - Count total results
|
|
2131
|
+
* - Iteration via for...of or spread
|
|
2132
|
+
*
|
|
2133
|
+
* @example
|
|
2134
|
+
* // Basic cartesian product
|
|
2135
|
+
* const pairs = combine([1, 2, 3], ['a', 'b']).all()
|
|
2136
|
+
* // [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b'], [3, 'a'], [3, 'b']]
|
|
2137
|
+
*
|
|
2138
|
+
* @example
|
|
2139
|
+
* // Chained transformations
|
|
2140
|
+
* const result = combine([1, 2, 3], [10, 20, 30])
|
|
2141
|
+
* .where(([a, b]) => a + b > 15)
|
|
2142
|
+
* .map(([a, b]) => a * b)
|
|
2143
|
+
* .take(3)
|
|
2144
|
+
* .all()
|
|
2145
|
+
* // [20, 30, 40]
|
|
2146
|
+
*
|
|
2147
|
+
* @example
|
|
2148
|
+
* // Lazy evaluation with for...of
|
|
2149
|
+
* for (const [x, y] of combine(range(1000), range(1000)).take(10)) {
|
|
2150
|
+
* console.log(x, y) // Only computes first 10 pairs
|
|
2151
|
+
* }
|
|
2152
|
+
*
|
|
2153
|
+
* @example
|
|
2154
|
+
* // From EntityCollection
|
|
2155
|
+
* await db.User.fetchAll()
|
|
2156
|
+
* const activeUsers = db.User
|
|
2157
|
+
* .where(user => user.isActive)
|
|
2158
|
+
* .map(user => user.email)
|
|
2159
|
+
* .all()
|
|
2160
|
+
*/
|
|
2161
|
+
export class Combinable {
|
|
2162
|
+
source;
|
|
2163
|
+
constructor(source) {
|
|
2164
|
+
this.source = source;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Filter combinations by predicate
|
|
2168
|
+
*/
|
|
2169
|
+
where(predicate) {
|
|
2170
|
+
const source = this.source;
|
|
2171
|
+
return new Combinable(function* () {
|
|
2172
|
+
for (const item of source()) {
|
|
2173
|
+
if (predicate(item)) {
|
|
2174
|
+
yield item;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Transform each combination
|
|
2181
|
+
*/
|
|
2182
|
+
map(fn) {
|
|
2183
|
+
const source = this.source;
|
|
2184
|
+
return new Combinable(function* () {
|
|
2185
|
+
for (const item of source()) {
|
|
2186
|
+
yield fn(item);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Limit to first n items
|
|
2192
|
+
*/
|
|
2193
|
+
take(n) {
|
|
2194
|
+
const source = this.source;
|
|
2195
|
+
return new Combinable(function* () {
|
|
2196
|
+
let count = 0;
|
|
2197
|
+
for (const item of source()) {
|
|
2198
|
+
if (count >= n)
|
|
2199
|
+
break;
|
|
2200
|
+
yield item;
|
|
2201
|
+
count++;
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Skip first n items
|
|
2207
|
+
*/
|
|
2208
|
+
skip(n) {
|
|
2209
|
+
const source = this.source;
|
|
2210
|
+
return new Combinable(function* () {
|
|
2211
|
+
let count = 0;
|
|
2212
|
+
for (const item of source()) {
|
|
2213
|
+
if (count >= n) {
|
|
2214
|
+
yield item;
|
|
2215
|
+
}
|
|
2216
|
+
count++;
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Collect all results into an array (terminal operation)
|
|
2222
|
+
*/
|
|
2223
|
+
all() {
|
|
2224
|
+
return [...this.source()];
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Get the first result or undefined (terminal operation)
|
|
2228
|
+
*/
|
|
2229
|
+
first() {
|
|
2230
|
+
const iterator = this.source();
|
|
2231
|
+
const result = iterator.next();
|
|
2232
|
+
return result.done ? undefined : result.value;
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Count total results (terminal operation)
|
|
2236
|
+
*/
|
|
2237
|
+
count() {
|
|
2238
|
+
let count = 0;
|
|
2239
|
+
for (const _ of this.source()) {
|
|
2240
|
+
count++;
|
|
2241
|
+
}
|
|
2242
|
+
return count;
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Make Combinable iterable with for...of and spread operator
|
|
2246
|
+
*/
|
|
2247
|
+
[Symbol.iterator]() {
|
|
2248
|
+
return this.source();
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Create cartesian product of multiple iterables.
|
|
2253
|
+
* Returns a lazy Combinable that only computes when iterated or terminal operation is called.
|
|
2254
|
+
*
|
|
2255
|
+
* @example
|
|
2256
|
+
* const result = combine([1, 2], ['a', 'b']).all()
|
|
2257
|
+
* // [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']]
|
|
2258
|
+
*
|
|
2259
|
+
* @example
|
|
2260
|
+
* const filtered = combine([1, 2, 3], [10, 20])
|
|
2261
|
+
* .where(([a, b]) => a + b > 20)
|
|
2262
|
+
* .map(([a, b]) => a * b)
|
|
2263
|
+
* .all()
|
|
2264
|
+
*/
|
|
2265
|
+
export function combine(...iterables) {
|
|
2266
|
+
// Convert iterables to arrays lazily (we need to iterate multiple times)
|
|
2267
|
+
// This is done inside the generator to maintain laziness
|
|
2268
|
+
return new Combinable(function* () {
|
|
2269
|
+
if (iterables.length === 0)
|
|
2270
|
+
return;
|
|
2271
|
+
// Convert to arrays (required for cartesian product - need multiple passes)
|
|
2272
|
+
const arrays = iterables.map((it) => [...it]);
|
|
2273
|
+
// Check for empty arrays - cartesian product with empty set is empty
|
|
2274
|
+
if (arrays.some((arr) => arr.length === 0))
|
|
2275
|
+
return;
|
|
2276
|
+
// Generate cartesian product using indices
|
|
2277
|
+
const indices = new Array(arrays.length).fill(0);
|
|
2278
|
+
const maxIndices = arrays.map((arr) => arr.length);
|
|
2279
|
+
while (true) {
|
|
2280
|
+
// Yield current combination
|
|
2281
|
+
yield indices.map((i, j) => arrays[j][i]);
|
|
2282
|
+
// Increment indices (like counting in mixed radix)
|
|
2283
|
+
let carry = true;
|
|
2284
|
+
for (let i = indices.length - 1; i >= 0 && carry; i--) {
|
|
2285
|
+
indices[i]++;
|
|
2286
|
+
if (indices[i] >= maxIndices[i]) {
|
|
2287
|
+
indices[i] = 0;
|
|
2288
|
+
}
|
|
2289
|
+
else {
|
|
2290
|
+
carry = false;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
// If we carried all the way, we're done
|
|
2294
|
+
if (carry)
|
|
2295
|
+
break;
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Simple YAML parser for frontmatter
|
|
2301
|
+
* Handles the subset of YAML commonly used in MDX frontmatter:
|
|
2302
|
+
* - Key-value pairs (string values)
|
|
2303
|
+
* - Nested objects (indentation-based)
|
|
2304
|
+
* - Arrays with bracket notation [item1, item2]
|
|
2305
|
+
* - Comments (# ...)
|
|
2306
|
+
* - Multiline strings with | and >
|
|
2307
|
+
*/
|
|
2308
|
+
function parseYAML(yaml) {
|
|
2309
|
+
// Normalize line endings to LF
|
|
2310
|
+
const normalized = yaml.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
2311
|
+
const lines = normalized.split('\n');
|
|
2312
|
+
const result = {};
|
|
2313
|
+
// Stack for tracking nested objects: [{ obj, indent }]
|
|
2314
|
+
const stack = [{ obj: result, indent: -1 }];
|
|
2315
|
+
// State for multiline strings
|
|
2316
|
+
let multilineKey = null;
|
|
2317
|
+
let multilineIndent = 0;
|
|
2318
|
+
let multilineLines = [];
|
|
2319
|
+
let multilineStyle = null;
|
|
2320
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
2321
|
+
const rawLine = lines[lineNum];
|
|
2322
|
+
// Handle multiline string continuation
|
|
2323
|
+
if (multilineKey !== null) {
|
|
2324
|
+
// Check if this line continues the multiline string
|
|
2325
|
+
const lineIndent = rawLine.match(/^(\s*)/)?.[1].length ?? 0;
|
|
2326
|
+
if (lineIndent >= multilineIndent && (rawLine.trim() !== '' || multilineLines.length > 0)) {
|
|
2327
|
+
// This is a continuation of the multiline string
|
|
2328
|
+
multilineLines.push(rawLine.slice(multilineIndent));
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
else {
|
|
2332
|
+
// End of multiline string - set the value
|
|
2333
|
+
const current = stack[stack.length - 1];
|
|
2334
|
+
if (multilineStyle === '|') {
|
|
2335
|
+
// Literal block - preserve newlines
|
|
2336
|
+
current.obj[multilineKey] = multilineLines.join('\n');
|
|
2337
|
+
}
|
|
2338
|
+
else {
|
|
2339
|
+
// Folded block - join with spaces (single newlines become spaces)
|
|
2340
|
+
current.obj[multilineKey] = multilineLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
2341
|
+
}
|
|
2342
|
+
multilineKey = null;
|
|
2343
|
+
multilineLines = [];
|
|
2344
|
+
multilineStyle = null;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
// Remove comments (but not inside quoted strings)
|
|
2348
|
+
let line = rawLine;
|
|
2349
|
+
// Simple comment removal - find # that's not inside quotes
|
|
2350
|
+
const hashIndex = line.indexOf('#');
|
|
2351
|
+
if (hashIndex !== -1) {
|
|
2352
|
+
// Check if it's inside quotes by counting quotes before it
|
|
2353
|
+
const beforeHash = line.slice(0, hashIndex);
|
|
2354
|
+
const singleQuotes = (beforeHash.match(/'/g) || []).length;
|
|
2355
|
+
const doubleQuotes = (beforeHash.match(/"/g) || []).length;
|
|
2356
|
+
if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0) {
|
|
2357
|
+
line = line.slice(0, hashIndex);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
// Skip empty lines
|
|
2361
|
+
if (line.trim() === '')
|
|
2362
|
+
continue;
|
|
2363
|
+
// Calculate indentation
|
|
2364
|
+
const indentMatch = line.match(/^(\s*)/);
|
|
2365
|
+
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
2366
|
+
const content = line.trim();
|
|
2367
|
+
// Pop stack back to appropriate level
|
|
2368
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
2369
|
+
stack.pop();
|
|
2370
|
+
}
|
|
2371
|
+
// Parse key-value pair
|
|
2372
|
+
const colonIndex = content.indexOf(':');
|
|
2373
|
+
if (colonIndex === -1) {
|
|
2374
|
+
throw new Error(`Invalid YAML at line ${lineNum + 1}: expected key-value pair, got "${content}"`);
|
|
2375
|
+
}
|
|
2376
|
+
const key = content.slice(0, colonIndex).trim();
|
|
2377
|
+
let value = content.slice(colonIndex + 1).trim();
|
|
2378
|
+
// Validate key
|
|
2379
|
+
if (!key) {
|
|
2380
|
+
throw new Error(`Invalid YAML at line ${lineNum + 1}: empty key`);
|
|
2381
|
+
}
|
|
2382
|
+
const current = stack[stack.length - 1];
|
|
2383
|
+
// Handle multiline string indicators
|
|
2384
|
+
if (value === '|' || value === '>') {
|
|
2385
|
+
multilineKey = key;
|
|
2386
|
+
multilineStyle = value;
|
|
2387
|
+
multilineIndent = indent + 2; // Expect content indented from the key
|
|
2388
|
+
multilineLines = [];
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
if (value === '') {
|
|
2392
|
+
// No value - this is a nested object
|
|
2393
|
+
const nested = {};
|
|
2394
|
+
current.obj[key] = nested;
|
|
2395
|
+
stack.push({ obj: nested, indent });
|
|
2396
|
+
}
|
|
2397
|
+
else if (value.startsWith('[') && !value.endsWith(']')) {
|
|
2398
|
+
// Unclosed array bracket
|
|
2399
|
+
throw new Error(`Invalid YAML at line ${lineNum + 1}: unclosed array bracket in "${content}"`);
|
|
2400
|
+
}
|
|
2401
|
+
else if (value.startsWith('[') && value.endsWith(']')) {
|
|
2402
|
+
// Array notation: [item1, item2, ...]
|
|
2403
|
+
const arrayContent = value.slice(1, -1).trim();
|
|
2404
|
+
if (arrayContent === '') {
|
|
2405
|
+
current.obj[key] = [];
|
|
2406
|
+
}
|
|
2407
|
+
else {
|
|
2408
|
+
// Parse array items - handle quoted strings and unquoted values
|
|
2409
|
+
const items = [];
|
|
2410
|
+
let currentItem = '';
|
|
2411
|
+
let inQuote = null;
|
|
2412
|
+
for (let i = 0; i < arrayContent.length; i++) {
|
|
2413
|
+
const char = arrayContent[i];
|
|
2414
|
+
if (inQuote) {
|
|
2415
|
+
if (char === inQuote) {
|
|
2416
|
+
inQuote = null;
|
|
2417
|
+
}
|
|
2418
|
+
else {
|
|
2419
|
+
currentItem += char;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
else if (char === '"' || char === "'") {
|
|
2423
|
+
inQuote = char;
|
|
2424
|
+
}
|
|
2425
|
+
else if (char === ',') {
|
|
2426
|
+
items.push(currentItem.trim());
|
|
2427
|
+
currentItem = '';
|
|
2428
|
+
}
|
|
2429
|
+
else {
|
|
2430
|
+
currentItem += char;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
if (currentItem.trim()) {
|
|
2434
|
+
items.push(currentItem.trim());
|
|
2435
|
+
}
|
|
2436
|
+
current.obj[key] = items;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
else {
|
|
2440
|
+
// String value - remove surrounding quotes if present
|
|
2441
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
2442
|
+
value = value.slice(1, -1);
|
|
2443
|
+
}
|
|
2444
|
+
current.obj[key] = value;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
// Handle any remaining multiline string
|
|
2448
|
+
if (multilineKey !== null) {
|
|
2449
|
+
const current = stack[stack.length - 1];
|
|
2450
|
+
if (multilineStyle === '|') {
|
|
2451
|
+
current.obj[multilineKey] = multilineLines.join('\n');
|
|
2452
|
+
}
|
|
2453
|
+
else {
|
|
2454
|
+
current.obj[multilineKey] = multilineLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
return result;
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Parse MDX frontmatter and extract schema definition.
|
|
2461
|
+
*
|
|
2462
|
+
* @param mdx - The MDX content with optional YAML frontmatter
|
|
2463
|
+
* @returns Object with parsed schema and remaining body
|
|
2464
|
+
* @throws Error if YAML syntax is invalid
|
|
2465
|
+
*
|
|
2466
|
+
* @example
|
|
2467
|
+
* const mdx = `---
|
|
2468
|
+
* $context: https://db.sb
|
|
2469
|
+
* Idea:
|
|
2470
|
+
* concept: What is the concept?
|
|
2471
|
+
* ---
|
|
2472
|
+
*
|
|
2473
|
+
* <App>content</App>`
|
|
2474
|
+
*
|
|
2475
|
+
* const { schema, body } = parseMDX(mdx)
|
|
2476
|
+
* // schema = { $context: 'https://db.sb', Idea: { concept: 'What is the concept?' } }
|
|
2477
|
+
* // body = '\n<App>content</App>'
|
|
2478
|
+
*/
|
|
2479
|
+
export function parseMDX(mdx) {
|
|
2480
|
+
// Normalize line endings
|
|
2481
|
+
const normalized = mdx.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
2482
|
+
// Check if MDX starts with frontmatter delimiter
|
|
2483
|
+
if (!normalized.startsWith('---')) {
|
|
2484
|
+
// No frontmatter - return empty schema and full content as body
|
|
2485
|
+
return {
|
|
2486
|
+
schema: {},
|
|
2487
|
+
body: mdx,
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
// Find the closing frontmatter delimiter
|
|
2491
|
+
// Start searching after the first ---
|
|
2492
|
+
const startIndex = 3; // Length of '---'
|
|
2493
|
+
let endIndex = -1;
|
|
2494
|
+
// Look for --- at the start of a line (after a newline)
|
|
2495
|
+
let searchStart = startIndex;
|
|
2496
|
+
while (searchStart < normalized.length) {
|
|
2497
|
+
const newlineIndex = normalized.indexOf('\n', searchStart);
|
|
2498
|
+
if (newlineIndex === -1)
|
|
2499
|
+
break;
|
|
2500
|
+
// Check if the next line starts with ---
|
|
2501
|
+
if (normalized.slice(newlineIndex + 1, newlineIndex + 4) === '---') {
|
|
2502
|
+
// Verify it's actually the closing delimiter (followed by newline or EOF)
|
|
2503
|
+
const afterDash = normalized[newlineIndex + 4];
|
|
2504
|
+
if (afterDash === undefined || afterDash === '\n' || afterDash === '\r') {
|
|
2505
|
+
endIndex = newlineIndex + 1;
|
|
2506
|
+
break;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
searchStart = newlineIndex + 1;
|
|
2510
|
+
}
|
|
2511
|
+
if (endIndex === -1) {
|
|
2512
|
+
throw new Error('Invalid MDX: unclosed frontmatter (missing closing ---)');
|
|
2513
|
+
}
|
|
2514
|
+
// Extract frontmatter YAML (between the two --- markers)
|
|
2515
|
+
const frontmatter = normalized.slice(startIndex, endIndex).trim();
|
|
2516
|
+
// Extract body (everything after the closing ---)
|
|
2517
|
+
const bodyStart = endIndex + 3; // Skip past ---
|
|
2518
|
+
let body = normalized.slice(bodyStart);
|
|
2519
|
+
// Remove leading newline from body if present (but preserve rest of whitespace)
|
|
2520
|
+
if (body.startsWith('\n')) {
|
|
2521
|
+
body = body.slice(1);
|
|
2522
|
+
}
|
|
2523
|
+
// Parse YAML frontmatter
|
|
2524
|
+
let schema;
|
|
2525
|
+
if (frontmatter === '') {
|
|
2526
|
+
// Empty frontmatter
|
|
2527
|
+
schema = {};
|
|
2528
|
+
}
|
|
2529
|
+
else {
|
|
2530
|
+
try {
|
|
2531
|
+
schema = parseYAML(frontmatter);
|
|
2532
|
+
}
|
|
2533
|
+
catch (error) {
|
|
2534
|
+
if (error instanceof Error) {
|
|
2535
|
+
throw new Error(`Invalid YAML in frontmatter: ${error.message}`);
|
|
2536
|
+
}
|
|
2537
|
+
throw error;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
return { schema, body };
|
|
2541
|
+
}
|
|
2542
|
+
// ============================================================================
|
|
2543
|
+
// JSX UI Specification Parser
|
|
2544
|
+
// ============================================================================
|
|
2545
|
+
import { parse as babelParse } from '@babel/parser';
|
|
2546
|
+
function isJSXElement(node) {
|
|
2547
|
+
return node?.type === 'JSXElement';
|
|
2548
|
+
}
|
|
2549
|
+
function isObjectExpression(node) {
|
|
2550
|
+
return node?.type === 'ObjectExpression';
|
|
2551
|
+
}
|
|
2552
|
+
function isArrayExpression(node) {
|
|
2553
|
+
return node?.type === 'ArrayExpression';
|
|
2554
|
+
}
|
|
2555
|
+
function isStringLiteral(node) {
|
|
2556
|
+
return node?.type === 'StringLiteral';
|
|
2557
|
+
}
|
|
2558
|
+
function isNumericLiteral(node) {
|
|
2559
|
+
return node?.type === 'NumericLiteral';
|
|
2560
|
+
}
|
|
2561
|
+
function isBooleanLiteral(node) {
|
|
2562
|
+
return node?.type === 'BooleanLiteral';
|
|
2563
|
+
}
|
|
2564
|
+
function isNullLiteral(node) {
|
|
2565
|
+
return node?.type === 'NullLiteral';
|
|
2566
|
+
}
|
|
2567
|
+
function isIdentifier(node) {
|
|
2568
|
+
return node?.type === 'Identifier';
|
|
2569
|
+
}
|
|
2570
|
+
function isArrowFunctionExpression(node) {
|
|
2571
|
+
return node?.type === 'ArrowFunctionExpression';
|
|
2572
|
+
}
|
|
2573
|
+
function isFunctionExpression(node) {
|
|
2574
|
+
return node?.type === 'FunctionExpression';
|
|
2575
|
+
}
|
|
2576
|
+
function isObjectProperty(node) {
|
|
2577
|
+
return node?.type === 'ObjectProperty';
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Get the element name from a JSX element
|
|
2581
|
+
*/
|
|
2582
|
+
function getElementName(element) {
|
|
2583
|
+
const name = element.openingElement.name;
|
|
2584
|
+
if (name.type === 'JSXIdentifier') {
|
|
2585
|
+
return name.name;
|
|
2586
|
+
}
|
|
2587
|
+
if (name.type === 'JSXMemberExpression') {
|
|
2588
|
+
// Handle Member.Expression style names
|
|
2589
|
+
const parts = [];
|
|
2590
|
+
let current = name;
|
|
2591
|
+
while (current.type === 'JSXMemberExpression') {
|
|
2592
|
+
parts.unshift(current.property.name);
|
|
2593
|
+
current = current.object;
|
|
2594
|
+
}
|
|
2595
|
+
if (current.type === 'JSXIdentifier') {
|
|
2596
|
+
parts.unshift(current.name);
|
|
2597
|
+
}
|
|
2598
|
+
return parts.join('.');
|
|
2599
|
+
}
|
|
2600
|
+
return '';
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Extract props from a JSX element
|
|
2604
|
+
*/
|
|
2605
|
+
function extractProps(element, sourceCode) {
|
|
2606
|
+
const props = {};
|
|
2607
|
+
for (const attr of element.openingElement.attributes) {
|
|
2608
|
+
if (attr.type === 'JSXAttribute') {
|
|
2609
|
+
const name = attr.name.type === 'JSXIdentifier' ? attr.name.name : String(attr.name.name);
|
|
2610
|
+
const value = attr.value;
|
|
2611
|
+
if (value === null || value === undefined) {
|
|
2612
|
+
// Boolean prop: <Field searchable />
|
|
2613
|
+
props[name] = true;
|
|
2614
|
+
}
|
|
2615
|
+
else if (value.type === 'StringLiteral') {
|
|
2616
|
+
props[name] = value.value;
|
|
2617
|
+
}
|
|
2618
|
+
else if (value.type === 'JSXExpressionContainer') {
|
|
2619
|
+
props[name] = evaluateExpression(value.expression, sourceCode);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
else if (attr.type === 'JSXSpreadAttribute') {
|
|
2623
|
+
// Handle spread attributes if needed
|
|
2624
|
+
const spreadValue = evaluateExpression(attr.argument, sourceCode);
|
|
2625
|
+
if (typeof spreadValue === 'object' && spreadValue !== null) {
|
|
2626
|
+
Object.assign(props, spreadValue);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
return props;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Evaluate a JSX expression to a JavaScript value
|
|
2634
|
+
*/
|
|
2635
|
+
function evaluateExpression(expr, sourceCode) {
|
|
2636
|
+
if (expr.type === 'JSXEmptyExpression') {
|
|
2637
|
+
return undefined;
|
|
2638
|
+
}
|
|
2639
|
+
if (isStringLiteral(expr)) {
|
|
2640
|
+
return expr.value;
|
|
2641
|
+
}
|
|
2642
|
+
if (isNumericLiteral(expr)) {
|
|
2643
|
+
return expr.value;
|
|
2644
|
+
}
|
|
2645
|
+
if (isBooleanLiteral(expr)) {
|
|
2646
|
+
return expr.value;
|
|
2647
|
+
}
|
|
2648
|
+
if (isNullLiteral(expr)) {
|
|
2649
|
+
return null;
|
|
2650
|
+
}
|
|
2651
|
+
if (isIdentifier(expr)) {
|
|
2652
|
+
// Handle special identifiers
|
|
2653
|
+
if (expr.name === 'undefined')
|
|
2654
|
+
return undefined;
|
|
2655
|
+
if (expr.name === 'true')
|
|
2656
|
+
return true;
|
|
2657
|
+
if (expr.name === 'false')
|
|
2658
|
+
return false;
|
|
2659
|
+
// Return identifier name as string (for field references)
|
|
2660
|
+
return expr.name;
|
|
2661
|
+
}
|
|
2662
|
+
if (isArrayExpression(expr)) {
|
|
2663
|
+
return expr.elements.map((el) => {
|
|
2664
|
+
if (el === null)
|
|
2665
|
+
return null;
|
|
2666
|
+
if (el.type === 'SpreadElement') {
|
|
2667
|
+
return evaluateExpression(el.argument, sourceCode);
|
|
2668
|
+
}
|
|
2669
|
+
return evaluateExpression(el, sourceCode);
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
if (isObjectExpression(expr)) {
|
|
2673
|
+
const obj = {};
|
|
2674
|
+
for (const prop of expr.properties) {
|
|
2675
|
+
if (isObjectProperty(prop)) {
|
|
2676
|
+
const key = isIdentifier(prop.key)
|
|
2677
|
+
? prop.key.name
|
|
2678
|
+
: isStringLiteral(prop.key)
|
|
2679
|
+
? prop.key.value
|
|
2680
|
+
: String(prop.key);
|
|
2681
|
+
obj[key] = evaluateExpression(prop.value, sourceCode);
|
|
2682
|
+
}
|
|
2683
|
+
// ObjectMethod handling for hooks
|
|
2684
|
+
if (prop.type === 'ObjectMethod') {
|
|
2685
|
+
const key = isIdentifier(prop.key)
|
|
2686
|
+
? prop.key.name
|
|
2687
|
+
: isStringLiteral(prop.key)
|
|
2688
|
+
? prop.key.value
|
|
2689
|
+
: String(prop.key);
|
|
2690
|
+
// Preserve function as source code string
|
|
2691
|
+
if (prop.start !== null && prop.end !== null) {
|
|
2692
|
+
obj[key] = sourceCode.slice(prop.start, prop.end);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
return obj;
|
|
2697
|
+
}
|
|
2698
|
+
if (isArrowFunctionExpression(expr) || isFunctionExpression(expr)) {
|
|
2699
|
+
// Preserve function source for hooks
|
|
2700
|
+
if (expr.start !== null && expr.end !== null) {
|
|
2701
|
+
return sourceCode.slice(expr.start, expr.end);
|
|
2702
|
+
}
|
|
2703
|
+
return '[Function]';
|
|
2704
|
+
}
|
|
2705
|
+
// For complex expressions, preserve as string
|
|
2706
|
+
if (expr.start !== null && expr.end !== null) {
|
|
2707
|
+
return sourceCode.slice(expr.start, expr.end);
|
|
2708
|
+
}
|
|
2709
|
+
return undefined;
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Get child elements of a JSX element, filtering out whitespace-only text
|
|
2713
|
+
*/
|
|
2714
|
+
function getChildElements(element) {
|
|
2715
|
+
return element.children.filter((child) => {
|
|
2716
|
+
if (!isJSXElement(child))
|
|
2717
|
+
return false;
|
|
2718
|
+
return true;
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Parse Field configuration from a JSX element
|
|
2723
|
+
*/
|
|
2724
|
+
function parseFieldConfig(element, sourceCode) {
|
|
2725
|
+
const props = extractProps(element, sourceCode);
|
|
2726
|
+
const config = {
|
|
2727
|
+
name: String(props.name || ''),
|
|
2728
|
+
};
|
|
2729
|
+
if (typeof props.lines === 'number')
|
|
2730
|
+
config.lines = props.lines;
|
|
2731
|
+
if (typeof props.placeholder === 'string')
|
|
2732
|
+
config.placeholder = props.placeholder;
|
|
2733
|
+
if (typeof props.display === 'string')
|
|
2734
|
+
config.display = props.display;
|
|
2735
|
+
if (props.allowCreate === true)
|
|
2736
|
+
config.allowCreate = true;
|
|
2737
|
+
if (props.searchable === true)
|
|
2738
|
+
config.searchable = true;
|
|
2739
|
+
if (Array.isArray(props.preview))
|
|
2740
|
+
config.preview = props.preview;
|
|
2741
|
+
if (props.collapsed === true)
|
|
2742
|
+
config.collapsed = true;
|
|
2743
|
+
if (props.readOnly === true)
|
|
2744
|
+
config.readOnly = true;
|
|
2745
|
+
if (props.required === true)
|
|
2746
|
+
config.required = true;
|
|
2747
|
+
if (typeof props.min === 'number')
|
|
2748
|
+
config.min = props.min;
|
|
2749
|
+
if (typeof props.max === 'number')
|
|
2750
|
+
config.max = props.max;
|
|
2751
|
+
if (typeof props.span === 'number')
|
|
2752
|
+
config.span = props.span;
|
|
2753
|
+
if (props.autoFocus === true)
|
|
2754
|
+
config.autoFocus = true;
|
|
2755
|
+
return config;
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Parse Section configuration from a JSX element
|
|
2759
|
+
*/
|
|
2760
|
+
function parseSectionConfig(element, sourceCode) {
|
|
2761
|
+
const props = extractProps(element, sourceCode);
|
|
2762
|
+
const config = {
|
|
2763
|
+
label: String(props.label || ''),
|
|
2764
|
+
};
|
|
2765
|
+
if (props.collapsed === true)
|
|
2766
|
+
config.collapsed = true;
|
|
2767
|
+
// Parse child fields and sections
|
|
2768
|
+
const children = [];
|
|
2769
|
+
for (const child of getChildElements(element)) {
|
|
2770
|
+
const childName = getElementName(child);
|
|
2771
|
+
if (childName === 'Field') {
|
|
2772
|
+
children.push(parseFieldConfig(child, sourceCode));
|
|
2773
|
+
}
|
|
2774
|
+
else if (childName === 'Section') {
|
|
2775
|
+
children.push(parseSectionConfig(child, sourceCode));
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
if (children.length > 0) {
|
|
2779
|
+
config.children = children;
|
|
2780
|
+
}
|
|
2781
|
+
return config;
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Parse Form configuration from a JSX element
|
|
2785
|
+
*/
|
|
2786
|
+
function parseFormConfig(element, sourceCode) {
|
|
2787
|
+
const props = extractProps(element, sourceCode);
|
|
2788
|
+
const config = {};
|
|
2789
|
+
if (typeof props.layout === 'string')
|
|
2790
|
+
config.layout = props.layout;
|
|
2791
|
+
if (Array.isArray(props.fields))
|
|
2792
|
+
config.fields = props.fields;
|
|
2793
|
+
// Parse child fields and sections
|
|
2794
|
+
const children = [];
|
|
2795
|
+
for (const child of getChildElements(element)) {
|
|
2796
|
+
const childName = getElementName(child);
|
|
2797
|
+
if (childName === 'Field') {
|
|
2798
|
+
children.push(parseFieldConfig(child, sourceCode));
|
|
2799
|
+
}
|
|
2800
|
+
else if (childName === 'Section') {
|
|
2801
|
+
children.push(parseSectionConfig(child, sourceCode));
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
if (children.length > 0) {
|
|
2805
|
+
config.children = children;
|
|
2806
|
+
}
|
|
2807
|
+
return config;
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Parse Template configuration, preserving JSX for runtime
|
|
2811
|
+
*/
|
|
2812
|
+
function parseTemplateConfig(element, sourceCode) {
|
|
2813
|
+
// Preserve the entire template JSX as a string
|
|
2814
|
+
if (element.start !== null && element.end !== null) {
|
|
2815
|
+
// Get just the children content
|
|
2816
|
+
const openingEnd = element.openingElement.end ?? element.start;
|
|
2817
|
+
const closingStart = element.closingElement?.start ?? element.end;
|
|
2818
|
+
const jsx = sourceCode.slice(openingEnd, closingStart).trim();
|
|
2819
|
+
return { jsx };
|
|
2820
|
+
}
|
|
2821
|
+
return { jsx: '' };
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Parse display component (Table, Grid, Cards)
|
|
2825
|
+
*/
|
|
2826
|
+
function parseDisplayConfig(element, sourceCode) {
|
|
2827
|
+
const name = getElementName(element);
|
|
2828
|
+
const props = extractProps(element, sourceCode);
|
|
2829
|
+
const config = {
|
|
2830
|
+
type: name.toLowerCase(),
|
|
2831
|
+
};
|
|
2832
|
+
if (Array.isArray(props.columns))
|
|
2833
|
+
config.columns = props.columns;
|
|
2834
|
+
if (props.sortable === true)
|
|
2835
|
+
config.sortable = true;
|
|
2836
|
+
// Look for Template child
|
|
2837
|
+
for (const child of getChildElements(element)) {
|
|
2838
|
+
if (getElementName(child) === 'Template') {
|
|
2839
|
+
config.template = parseTemplateConfig(child, sourceCode);
|
|
2840
|
+
break;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return config;
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Parse Search configuration
|
|
2847
|
+
*/
|
|
2848
|
+
function parseSearchConfig(element, sourceCode) {
|
|
2849
|
+
const props = extractProps(element, sourceCode);
|
|
2850
|
+
const config = {};
|
|
2851
|
+
if (Array.isArray(props.fields))
|
|
2852
|
+
config.fields = props.fields;
|
|
2853
|
+
return config;
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2856
|
+
* Parse Filter configuration
|
|
2857
|
+
*/
|
|
2858
|
+
function parseFilterConfig(element, sourceCode) {
|
|
2859
|
+
const props = extractProps(element, sourceCode);
|
|
2860
|
+
return {
|
|
2861
|
+
field: String(props.field || ''),
|
|
2862
|
+
exists: props.exists === true ? true : undefined,
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Parse view configuration (List, Detail, Edit, Create)
|
|
2867
|
+
*/
|
|
2868
|
+
function parseViewConfig(element, sourceCode) {
|
|
2869
|
+
const config = {};
|
|
2870
|
+
for (const child of getChildElements(element)) {
|
|
2871
|
+
const childName = getElementName(child);
|
|
2872
|
+
switch (childName) {
|
|
2873
|
+
case 'Search':
|
|
2874
|
+
config.search = parseSearchConfig(child, sourceCode);
|
|
2875
|
+
break;
|
|
2876
|
+
case 'Filters': {
|
|
2877
|
+
config.filters = [];
|
|
2878
|
+
for (const filterChild of getChildElements(child)) {
|
|
2879
|
+
if (getElementName(filterChild) === 'Filter') {
|
|
2880
|
+
config.filters.push(parseFilterConfig(filterChild, sourceCode));
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
break;
|
|
2884
|
+
}
|
|
2885
|
+
case 'Table':
|
|
2886
|
+
case 'Grid':
|
|
2887
|
+
case 'Cards':
|
|
2888
|
+
config.display = parseDisplayConfig(child, sourceCode);
|
|
2889
|
+
break;
|
|
2890
|
+
case 'Form':
|
|
2891
|
+
config.form = parseFormConfig(child, sourceCode);
|
|
2892
|
+
break;
|
|
2893
|
+
case 'Template':
|
|
2894
|
+
config.template = parseTemplateConfig(child, sourceCode);
|
|
2895
|
+
break;
|
|
2896
|
+
case 'Editor': {
|
|
2897
|
+
const props = extractProps(child, sourceCode);
|
|
2898
|
+
config.editor = { language: String(props.language || 'yaml') };
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
return config;
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* Parse Actions container
|
|
2907
|
+
*/
|
|
2908
|
+
function parseActionsConfig(element, schemaTypes, sourceCode) {
|
|
2909
|
+
const actions = [];
|
|
2910
|
+
for (const child of getChildElements(element)) {
|
|
2911
|
+
const childName = getElementName(child);
|
|
2912
|
+
const props = extractProps(child, sourceCode);
|
|
2913
|
+
if (childName === 'Delete') {
|
|
2914
|
+
actions.push({
|
|
2915
|
+
type: 'delete',
|
|
2916
|
+
confirm: typeof props.confirm === 'string' ? props.confirm : undefined,
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
else if (childName === 'Duplicate') {
|
|
2920
|
+
actions.push({ type: 'duplicate' });
|
|
2921
|
+
}
|
|
2922
|
+
else if (childName === 'Export') {
|
|
2923
|
+
actions.push({
|
|
2924
|
+
type: 'export',
|
|
2925
|
+
formats: Array.isArray(props.formats) ? props.formats : undefined,
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
else if (childName === 'Import') {
|
|
2929
|
+
actions.push({ type: 'import' });
|
|
2930
|
+
}
|
|
2931
|
+
else if (schemaTypes.has(childName)) {
|
|
2932
|
+
// Entity reference = generate action
|
|
2933
|
+
actions.push({
|
|
2934
|
+
type: 'generate',
|
|
2935
|
+
entity: childName,
|
|
2936
|
+
context: Array.isArray(props.context) ? props.context : undefined,
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
return actions;
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Parse Permissions container
|
|
2944
|
+
*/
|
|
2945
|
+
function parsePermissionsConfig(element, sourceCode) {
|
|
2946
|
+
const roles = [];
|
|
2947
|
+
for (const child of getChildElements(element)) {
|
|
2948
|
+
if (getElementName(child) === 'Role') {
|
|
2949
|
+
const props = extractProps(child, sourceCode);
|
|
2950
|
+
roles.push({
|
|
2951
|
+
name: String(props.name || ''),
|
|
2952
|
+
actions: Array.isArray(props.actions) ? props.actions : [],
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
return roles;
|
|
2957
|
+
}
|
|
2958
|
+
/**
|
|
2959
|
+
* Parse a collection element (schema type)
|
|
2960
|
+
*/
|
|
2961
|
+
function parseCollectionConfig(element, schemaTypes, sourceCode) {
|
|
2962
|
+
const props = extractProps(element, sourceCode);
|
|
2963
|
+
const config = {};
|
|
2964
|
+
// Top-level collection props
|
|
2965
|
+
if (typeof props.view === 'string')
|
|
2966
|
+
config.view = props.view;
|
|
2967
|
+
if (Array.isArray(props.columns))
|
|
2968
|
+
config.columns = props.columns;
|
|
2969
|
+
if (props.searchable === true)
|
|
2970
|
+
config.searchable = true;
|
|
2971
|
+
if (typeof props.path === 'string')
|
|
2972
|
+
config.path = props.path;
|
|
2973
|
+
if (typeof props.slug === 'string')
|
|
2974
|
+
config.slug = props.slug;
|
|
2975
|
+
// Parse child views and configurations
|
|
2976
|
+
for (const child of getChildElements(element)) {
|
|
2977
|
+
const childName = getElementName(child);
|
|
2978
|
+
switch (childName) {
|
|
2979
|
+
case 'List':
|
|
2980
|
+
config.list = parseViewConfig(child, sourceCode);
|
|
2981
|
+
break;
|
|
2982
|
+
case 'Detail':
|
|
2983
|
+
config.detail = parseViewConfig(child, sourceCode);
|
|
2984
|
+
break;
|
|
2985
|
+
case 'Edit':
|
|
2986
|
+
config.edit = parseViewConfig(child, sourceCode);
|
|
2987
|
+
break;
|
|
2988
|
+
case 'Create':
|
|
2989
|
+
config.create = parseViewConfig(child, sourceCode);
|
|
2990
|
+
break;
|
|
2991
|
+
case 'Actions':
|
|
2992
|
+
config.actions = parseActionsConfig(child, schemaTypes, sourceCode);
|
|
2993
|
+
break;
|
|
2994
|
+
case 'Permissions':
|
|
2995
|
+
config.permissions = parsePermissionsConfig(child, sourceCode);
|
|
2996
|
+
break;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
return config;
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* Parse navigation structure, distinguishing between groups and collections
|
|
3003
|
+
*/
|
|
3004
|
+
function parseNavigationElement(element, schemaTypes, collections, sourceCode) {
|
|
3005
|
+
const name = getElementName(element);
|
|
3006
|
+
const props = extractProps(element, sourceCode);
|
|
3007
|
+
// If this is a schema type, it's a collection
|
|
3008
|
+
if (schemaTypes.has(name)) {
|
|
3009
|
+
// Parse and store collection config
|
|
3010
|
+
collections[name] = parseCollectionConfig(element, schemaTypes, sourceCode);
|
|
3011
|
+
return name;
|
|
3012
|
+
}
|
|
3013
|
+
// Otherwise it's a navigation group
|
|
3014
|
+
const group = {
|
|
3015
|
+
name,
|
|
3016
|
+
children: [],
|
|
3017
|
+
};
|
|
3018
|
+
if (typeof props.icon === 'string') {
|
|
3019
|
+
group.icon = props.icon;
|
|
3020
|
+
}
|
|
3021
|
+
// Parse children
|
|
3022
|
+
for (const child of getChildElements(element)) {
|
|
3023
|
+
const childResult = parseNavigationElement(child, schemaTypes, collections, sourceCode);
|
|
3024
|
+
group.children.push(childResult);
|
|
3025
|
+
}
|
|
3026
|
+
return group;
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* Parse hooks from the App element's hooks prop
|
|
3030
|
+
*/
|
|
3031
|
+
function parseHooksFromProp(hooksProp, sourceCode) {
|
|
3032
|
+
const hooks = {};
|
|
3033
|
+
if (typeof hooksProp !== 'object' || hooksProp === null) {
|
|
3034
|
+
return hooks;
|
|
3035
|
+
}
|
|
3036
|
+
for (const [typeName, typeHooks] of Object.entries(hooksProp)) {
|
|
3037
|
+
if (typeof typeHooks !== 'object' || typeHooks === null)
|
|
3038
|
+
continue;
|
|
3039
|
+
const entityHooks = {};
|
|
3040
|
+
const hooksObj = typeHooks;
|
|
3041
|
+
// Preserve hooks as function source strings
|
|
3042
|
+
if (hooksObj.onCreate !== undefined) {
|
|
3043
|
+
entityHooks.onCreate = hooksObj.onCreate;
|
|
3044
|
+
}
|
|
3045
|
+
if (hooksObj.onUpdate !== undefined) {
|
|
3046
|
+
entityHooks.onUpdate = hooksObj.onUpdate;
|
|
3047
|
+
}
|
|
3048
|
+
if (hooksObj.onDelete !== undefined) {
|
|
3049
|
+
entityHooks.onDelete = hooksObj.onDelete;
|
|
3050
|
+
}
|
|
3051
|
+
if (Object.keys(entityHooks).length > 0) {
|
|
3052
|
+
hooks[typeName] = entityHooks;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
return hooks;
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Parse JSX UI specification into UIConfig
|
|
3059
|
+
*
|
|
3060
|
+
* @param jsx - The JSX string to parse
|
|
3061
|
+
* @param schema - The parsed schema to determine which elements are collections
|
|
3062
|
+
* @returns Parsed UI configuration
|
|
3063
|
+
* @throws Error if JSX syntax is invalid or no App root element
|
|
3064
|
+
*
|
|
3065
|
+
* @example
|
|
3066
|
+
* const jsx = `
|
|
3067
|
+
* <App hooks={{ Startup: { onCreate: async () => {} } }}>
|
|
3068
|
+
* <Planning icon="lightbulb">
|
|
3069
|
+
* <Idea view="cards" />
|
|
3070
|
+
* </Planning>
|
|
3071
|
+
* </App>
|
|
3072
|
+
* `
|
|
3073
|
+
* const config = parseJSXUI(jsx, schema)
|
|
3074
|
+
* // config.hooks = { Startup: { onCreate: ... } }
|
|
3075
|
+
* // config.navigation = [{ name: 'Planning', icon: 'lightbulb', children: ['Idea'] }]
|
|
3076
|
+
* // config.collections = { Idea: { view: 'cards' } }
|
|
3077
|
+
*/
|
|
3078
|
+
export function parseJSXUI(jsx, schema) {
|
|
3079
|
+
// Parse JSX using Babel
|
|
3080
|
+
let ast;
|
|
3081
|
+
try {
|
|
3082
|
+
ast = babelParse(jsx, {
|
|
3083
|
+
plugins: ['jsx'],
|
|
3084
|
+
sourceType: 'module',
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
catch (error) {
|
|
3088
|
+
if (error instanceof Error) {
|
|
3089
|
+
throw new Error(`Invalid JSX syntax: ${error.message}`);
|
|
3090
|
+
}
|
|
3091
|
+
throw new Error('Invalid JSX syntax');
|
|
3092
|
+
}
|
|
3093
|
+
// Get schema type names for distinguishing collections from groups
|
|
3094
|
+
const schemaTypes = new Set(Object.keys(schema.types));
|
|
3095
|
+
// Find the root App element
|
|
3096
|
+
let appElement = null;
|
|
3097
|
+
// Walk the AST to find JSX elements
|
|
3098
|
+
function findApp(node) {
|
|
3099
|
+
if (isJSXElement(node)) {
|
|
3100
|
+
if (getElementName(node) === 'App') {
|
|
3101
|
+
appElement = node;
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
// Check children
|
|
3105
|
+
for (const child of node.children) {
|
|
3106
|
+
findApp(child);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
// Handle ExpressionStatement wrapping JSX
|
|
3110
|
+
if (node.type === 'ExpressionStatement' && isJSXElement(node.expression)) {
|
|
3111
|
+
findApp(node.expression);
|
|
3112
|
+
}
|
|
3113
|
+
// Handle Program body
|
|
3114
|
+
if (node.type === 'Program') {
|
|
3115
|
+
for (const stmt of node.body) {
|
|
3116
|
+
findApp(stmt);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
// Handle File
|
|
3120
|
+
if (node.type === 'File') {
|
|
3121
|
+
findApp(node.program);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
findApp(ast);
|
|
3125
|
+
if (!appElement) {
|
|
3126
|
+
throw new Error('No <App> root element found in JSX');
|
|
3127
|
+
}
|
|
3128
|
+
// Initialize result
|
|
3129
|
+
const result = {
|
|
3130
|
+
hooks: {},
|
|
3131
|
+
navigation: [],
|
|
3132
|
+
collections: {},
|
|
3133
|
+
};
|
|
3134
|
+
// Extract hooks from App props
|
|
3135
|
+
const appProps = extractProps(appElement, jsx);
|
|
3136
|
+
if (appProps.hooks) {
|
|
3137
|
+
result.hooks = parseHooksFromProp(appProps.hooks, jsx);
|
|
3138
|
+
}
|
|
3139
|
+
// Parse children of App as navigation/collections
|
|
3140
|
+
for (const child of getChildElements(appElement)) {
|
|
3141
|
+
const parsed = parseNavigationElement(child, schemaTypes, result.collections, jsx);
|
|
3142
|
+
if (typeof parsed === 'string') {
|
|
3143
|
+
// Top-level collection (unusual but possible)
|
|
3144
|
+
result.navigation.push({
|
|
3145
|
+
name: parsed,
|
|
3146
|
+
children: [],
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
else {
|
|
3150
|
+
result.navigation.push(parsed);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
return result;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Process an MDX file and return all parsed components.
|
|
3157
|
+
*/
|
|
3158
|
+
export function processMDX(mdx) {
|
|
3159
|
+
const { schema, body } = parseMDX(mdx);
|
|
3160
|
+
const parsedSchema = parseSchema(schema);
|
|
3161
|
+
const uiConfig = parseJSXUI(body, parsedSchema);
|
|
3162
|
+
return { schema, parsedSchema, uiConfig, rawBody: body };
|
|
3163
|
+
}
|
|
3164
|
+
//# sourceMappingURL=index.js.map
|