@objectql/platform-node 1.6.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/src/driver.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { Driver } from '@objectql/types';
2
+
3
+ export function createDriverFromConnection(connection: string): Driver {
4
+ let driverPackage = '';
5
+ let driverClass = '';
6
+ let driverConfig: any = {};
7
+
8
+ if (connection.startsWith('mongodb://')) {
9
+ driverPackage = '@objectql/driver-mongo';
10
+ driverClass = 'MongoDriver';
11
+ driverConfig = { url: connection };
12
+ }
13
+ else if (connection.startsWith('sqlite://')) {
14
+ driverPackage = '@objectql/driver-sql';
15
+ driverClass = 'KnexDriver';
16
+ const filename = connection.replace('sqlite://', '');
17
+ driverConfig = {
18
+ client: 'sqlite3',
19
+ connection: { filename },
20
+ useNullAsDefault: true
21
+ };
22
+ }
23
+ else if (connection.startsWith('postgres://') || connection.startsWith('postgresql://')) {
24
+ driverPackage = '@objectql/driver-sql';
25
+ driverClass = 'KnexDriver';
26
+ driverConfig = {
27
+ client: 'pg',
28
+ connection: connection
29
+ };
30
+ }
31
+ else if (connection.startsWith('mysql://')) {
32
+ driverPackage = '@objectql/driver-sql';
33
+ driverClass = 'KnexDriver';
34
+ driverConfig = {
35
+ client: 'mysql2',
36
+ connection: connection
37
+ };
38
+ }
39
+ else {
40
+ throw new Error(`Unsupported connection protocol: ${connection}`);
41
+ }
42
+
43
+ try {
44
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
45
+ const pkg = require(driverPackage);
46
+ const DriverClass = pkg[driverClass];
47
+ if (!DriverClass) {
48
+ throw new Error(`${driverClass} not found in ${driverPackage}`);
49
+ }
50
+ return new DriverClass(driverConfig);
51
+ } catch (e: any) {
52
+ throw new Error(`Failed to load driver ${driverPackage}. Please install it: npm install ${driverPackage}. Error: ${e.message}`);
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './loader';
2
+ export * from './plugin';
3
+ export * from './driver';
package/src/loader.ts ADDED
@@ -0,0 +1,349 @@
1
+ import * as fs from 'fs';
2
+ import * as glob from 'fast-glob';
3
+ import * as path from 'path';
4
+ import { MetadataRegistry, ObjectConfig, LoaderPlugin, LoaderHandlerContext, FieldConfig } from '@objectql/types';
5
+ import * as yaml from 'js-yaml';
6
+ import { toTitleCase } from '@objectql/core';
7
+
8
+ export class ObjectLoader {
9
+ private plugins: LoaderPlugin[] = [];
10
+
11
+ constructor(protected registry: MetadataRegistry) {
12
+ this.registerBuiltinPlugins();
13
+ }
14
+
15
+ private registerBuiltinPlugins() {
16
+ // Objects
17
+ this.use({
18
+ name: 'object',
19
+ glob: ['**/*.object.yml', '**/*.object.yaml'],
20
+ handler: (ctx) => {
21
+ try {
22
+ const doc = yaml.load(ctx.content) as any;
23
+ if (!doc) return;
24
+
25
+ // Calculate ID from filename
26
+ const basename = path.basename(ctx.file);
27
+ const filenameId = basename.replace(/\.object\.(yml|yaml)$/, '');
28
+
29
+ // 1. Single Object definition (Standard)
30
+ // If fields are present, we treat it as a single object definition
31
+ if (doc.fields) {
32
+ if (!doc.name) {
33
+ // If name is missing, infer from filename
34
+ doc.name = filenameId;
35
+ } else if (doc.name !== filenameId) {
36
+ // warn if mismatch
37
+ console.warn(`[ObjectQL] Warning: Object name '${doc.name}' in ${basename} does not match filename. Using '${doc.name}'.`);
38
+ }
39
+
40
+ const packageEntry = ctx.registry.getEntry('package-map', ctx.file);
41
+ registerObject(ctx.registry, doc, ctx.file, ctx.packageName || (packageEntry && packageEntry.package));
42
+ return;
43
+ }
44
+
45
+ // 2. Multi-object map (Legacy/Bundle mode)
46
+ // e.g. { object1: { fields... }, object2: { fields... } }
47
+ for (const [key, value] of Object.entries(doc)) {
48
+ if (typeof value === 'object' && (value as any).fields) {
49
+ const obj = value as any;
50
+ if (!obj.name) obj.name = key;
51
+ registerObject(ctx.registry, obj, ctx.file, ctx.packageName);
52
+ }
53
+ }
54
+ } catch (e) {
55
+ console.error(`Error loading object from ${ctx.file}:`, e);
56
+ }
57
+ }
58
+ });
59
+
60
+ // Hooks
61
+ this.use({
62
+ name: 'hook',
63
+ glob: ['**/*.hook.ts', '**/*.hook.js'],
64
+ handler: (ctx) => {
65
+ const basename = path.basename(ctx.file);
66
+ // Extract object name from filename: user.hook.ts -> user
67
+ const objectName = basename.replace(/\.hook\.(ts|js)$/, '');
68
+
69
+ try {
70
+ const mod = require(ctx.file);
71
+ // Support default export or named exports
72
+ const hooks = mod.default || mod;
73
+
74
+ ctx.registry.register('hook', {
75
+ type: 'hook',
76
+ id: objectName, // Hook ID is the object name
77
+ path: ctx.file,
78
+ package: ctx.packageName,
79
+ content: hooks
80
+ });
81
+ } catch (e) {
82
+ console.error(`Error loading hook from ${ctx.file}:`, e);
83
+ }
84
+ }
85
+ });
86
+
87
+ // Actions
88
+ this.use({
89
+ name: 'action',
90
+ glob: ['**/*.action.ts', '**/*.action.js'],
91
+ handler: (ctx) => {
92
+ const basename = path.basename(ctx.file);
93
+ // Extract object name: invoice.action.ts -> invoice
94
+ const objectName = basename.replace(/\.action\.(ts|js)$/, '');
95
+
96
+ try {
97
+ const mod = require(ctx.file);
98
+
99
+ const actions: Record<string, any> = {};
100
+
101
+ for (const [key, value] of Object.entries(mod)) {
102
+ if (key === 'default') continue;
103
+ if (typeof value === 'object' && (value as any).handler) {
104
+ actions[key] = value;
105
+ }
106
+ }
107
+
108
+ if (Object.keys(actions).length > 0) {
109
+ ctx.registry.register('action', {
110
+ type: 'action',
111
+ id: objectName, // Action collection ID is the object name
112
+ path: ctx.file,
113
+ package: ctx.packageName,
114
+ content: actions
115
+ });
116
+ }
117
+
118
+ } catch (e) {
119
+ console.error(`Error loading action from ${ctx.file}:`, e);
120
+ }
121
+ }
122
+ });
123
+
124
+ // Generic YAML Metadata Loaders
125
+ const metaTypes = ['view', 'form', 'permission', 'report', 'workflow', 'validation', 'data', 'app', 'page', 'menu'];
126
+
127
+ for (const type of metaTypes) {
128
+ this.use({
129
+ name: type,
130
+ glob: [`**/*.${type}.yml`, `**/*.${type}.yaml`],
131
+ handler: (ctx) => {
132
+ try {
133
+ const doc = yaml.load(ctx.content) as any;
134
+ if (!doc) return;
135
+
136
+ // Use 'name' from doc, or filename base (without extension)
137
+ let id = doc.name;
138
+ if (!id) {
139
+ const basename = path.basename(ctx.file);
140
+ // e.g. "my-view.view.yml" -> "my-view"
141
+ // Regex: remove .type.yml or .type.yaml
142
+ const re = new RegExp(`\\.${type}\\.(yml|yaml)$`);
143
+ id = basename.replace(re, '');
144
+ }
145
+
146
+ // Ensure name is in the doc for consistency
147
+ if (!doc.name) doc.name = id;
148
+
149
+ ctx.registry.register(type, {
150
+ type: type,
151
+ id: id,
152
+ path: ctx.file,
153
+ package: ctx.packageName,
154
+ content: doc
155
+ });
156
+ } catch (e) {
157
+ console.error(`Error loading ${type} from ${ctx.file}:`, e);
158
+ }
159
+ }
160
+ })
161
+ }
162
+ }
163
+
164
+ use(plugin: LoaderPlugin) {
165
+ this.plugins.push(plugin);
166
+ }
167
+
168
+ load(dir: string, packageName?: string) {
169
+ for (const plugin of this.plugins) {
170
+ this.runPlugin(plugin, dir, packageName);
171
+ }
172
+ }
173
+
174
+ loadPackage(packageName: string) {
175
+ try {
176
+ const entryPath = require.resolve(packageName, { paths: [process.cwd()] });
177
+ // clean cache
178
+ delete require.cache[entryPath];
179
+ const packageDir = path.dirname(entryPath);
180
+ this.load(packageDir, packageName);
181
+ } catch (e) {
182
+ // fallback to directory
183
+ this.load(packageName, packageName);
184
+ }
185
+ }
186
+
187
+ private runPlugin(plugin: LoaderPlugin, dir: string, packageName?: string) {
188
+ // Enforce path conventions:
189
+ // 1. Never scan node_modules (unless explicitly loaded via loadPackage which sets cwd inside it)
190
+ // 2. Ignore build artifacts (dist, build, out) to avoid double-loading metadata if both src and dist exist.
191
+ // Note: If you want to load from 'dist', you must explicitly point the loader to it (e.g. loader.load('./dist')).
192
+ // In that case, the patterns won't match relative to the CWD.
193
+ // Path conventions:
194
+ // 1. Always ignore node_modules and .git
195
+ const ignore = [
196
+ '**/node_modules/**',
197
+ '**/.git/**'
198
+ ];
199
+
200
+ // 2. Intelligent handling of build artifacts (dist/build)
201
+ // If 'src' exists in the scan directory, we assume it's a Development Environment.
202
+ // In Dev, we ignore 'dist' to avoid duplicate loading (ts in src vs js in dist).
203
+ // In Production (no src), we must NOT ignore 'dist', otherwise we can't load compiled hooks/actions.
204
+ const srcPath = path.join(dir, 'src');
205
+ const hasSrc = fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory();
206
+
207
+ if (hasSrc) {
208
+ ignore.push('**/dist/**', '**/build/**', '**/out/**');
209
+ }
210
+
211
+ // 3. User instruction: "src 不行的" (src is not viable for metadata in production)
212
+ // Metadata (.yml) should ideally be placed in 'objects/' or root, not 'src/',
213
+ // to simplify packaging (so you don't need to copy assets from src to dist).
214
+ // However, we do not strictly block 'src' scanning here to avoid breaking dev workflow.
215
+ // The exclusion of 'dist' in dev mode (above) handles the code duality.
216
+
217
+ const files = glob.sync(plugin.glob, {
218
+ cwd: dir,
219
+ absolute: true,
220
+ ignore
221
+ });
222
+
223
+ for (const file of files) {
224
+ try {
225
+ const ctx: LoaderHandlerContext = {
226
+ file,
227
+ content: '',
228
+ registry: this.registry,
229
+ packageName
230
+ };
231
+
232
+ // Pre-read for convenience
233
+ if (!file.match(/\.(js|ts|node)$/)) {
234
+ ctx.content = fs.readFileSync(file, 'utf8');
235
+ }
236
+
237
+ plugin.handler(ctx);
238
+
239
+ } catch (e) {
240
+ console.error(`Error in loader plugin '${plugin.name}' processing ${file}:`, e);
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ function registerObject(registry: MetadataRegistry, obj: any, file: string, packageName?: string) {
247
+ if (!obj.name) return;
248
+
249
+ // --- Smart Defaults & Normalization ---
250
+
251
+ // 1. Object Label: Infer from name if missing
252
+ if (!obj.label) {
253
+ obj.label = toTitleCase(obj.name);
254
+ }
255
+
256
+ // 2. Normalize Fields
257
+ if (obj.fields) {
258
+ for (const [key, field] of Object.entries(obj.fields)) {
259
+ if (typeof field === 'object' && field !== null) {
260
+ const f = field as FieldConfig;
261
+
262
+ // Ensure field has a name
263
+ if (!f.name) {
264
+ f.name = key;
265
+ }
266
+
267
+ // Field Label: Infer from key if missing
268
+ if (!f.label) {
269
+ f.label = toTitleCase(key);
270
+ }
271
+
272
+ // Inferred Types
273
+ if (!f.type) {
274
+ if (f.reference_to) {
275
+ f.type = 'lookup';
276
+ } else if (f.options) {
277
+ f.type = 'select';
278
+ } else if (f.formula) {
279
+ f.type = 'formula';
280
+ } else if (f.summary_object) {
281
+ f.type = 'summary';
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // --- End Smart Defaults ---
289
+
290
+ // Check for existing object to Merge
291
+ const existing = registry.getEntry('object', obj.name);
292
+ if (existing) {
293
+ const base = existing.content;
294
+
295
+ // Merge Fields: New fields overwrite old ones
296
+ if (obj.fields) {
297
+ base.fields = { ...base.fields, ...obj.fields };
298
+ }
299
+
300
+ // Merge Actions
301
+ if (obj.actions) {
302
+ base.actions = { ...base.actions, ...obj.actions };
303
+ }
304
+
305
+ // Merge Indexes
306
+ if (obj.indexes) {
307
+ base.indexes = { ...base.indexes, ...obj.indexes };
308
+ }
309
+
310
+ // Override Top-level Properties if provided
311
+ if (obj.label) base.label = obj.label;
312
+ if (obj.icon) base.icon = obj.icon;
313
+ if (obj.description) base.description = obj.description;
314
+ if (obj.datasource) base.datasource = obj.datasource;
315
+
316
+ // Update the content reference
317
+ existing.content = base;
318
+ return;
319
+ }
320
+
321
+ registry.register('object', {
322
+ type: 'object',
323
+ id: obj.name,
324
+ path: file,
325
+ package: packageName,
326
+ content: obj
327
+ });
328
+ }
329
+
330
+ export function loadObjectConfigs(dir: string): Record<string, ObjectConfig> {
331
+ const registry = new MetadataRegistry();
332
+ const loader = new ObjectLoader(registry);
333
+ loader.load(dir);
334
+
335
+ // Merge actions into objects
336
+ const actions = registry.list<any>('action');
337
+ for (const act of actions) {
338
+ const obj = registry.get<ObjectConfig>('object', act.id);
339
+ if (obj) {
340
+ obj.actions = act.content;
341
+ }
342
+ }
343
+
344
+ const result: Record<string, ObjectConfig> = {};
345
+ for (const obj of registry.list<ObjectConfig>('object')) {
346
+ result[obj.name] = obj;
347
+ }
348
+ return result;
349
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { ObjectQLPlugin } from '@objectql/types';
2
+
3
+ export function loadPlugin(packageName: string): ObjectQLPlugin {
4
+ let mod: any;
5
+ try {
6
+ const modulePath = require.resolve(packageName, { paths: [process.cwd()] });
7
+ mod = require(modulePath);
8
+ } catch (e) {
9
+ throw new Error(`Failed to resolve plugin '${packageName}': ${e}`);
10
+ }
11
+
12
+ // Helper to find plugin instance
13
+ const findPlugin = (candidate: any): ObjectQLPlugin | undefined => {
14
+ if (!candidate) return undefined;
15
+
16
+ // 1. Try treating as Class
17
+ if (typeof candidate === 'function') {
18
+ try {
19
+ const inst = new candidate();
20
+ if (inst && typeof inst.setup === 'function') {
21
+ return inst; // Found it!
22
+ }
23
+ } catch (e) {
24
+ // Not a constructor or instantiation failed
25
+ }
26
+ }
27
+
28
+ // 2. Try treating as Instance
29
+ if (candidate && typeof candidate.setup === 'function') {
30
+ if (candidate.name) return candidate;
31
+ }
32
+ return undefined;
33
+ };
34
+
35
+ // Search in default, module root, and all named exports
36
+ let instance = findPlugin(mod.default) || findPlugin(mod);
37
+
38
+ if (!instance && mod && typeof mod === 'object') {
39
+ for (const key of Object.keys(mod)) {
40
+ if (key === 'default') continue;
41
+ instance = findPlugin(mod[key]);
42
+ if (instance) break;
43
+ }
44
+ }
45
+
46
+ if (instance) {
47
+ (instance as any)._packageName = packageName;
48
+ return instance;
49
+ } else {
50
+ console.error(`[PluginLoader] Failed to find ObjectQLPlugin in '${packageName}'. Exports:`, Object.keys(mod));
51
+ throw new Error(`Plugin '${packageName}' must export a class or object implementing ObjectQLPlugin.`);
52
+ }
53
+ }
@@ -0,0 +1,33 @@
1
+ import { ObjectQL } from '@objectql/core';
2
+ import { ObjectLoader } from '../src';
3
+ import * as path from 'path';
4
+
5
+ describe('Dynamic Package Loading', () => {
6
+ let objectql: ObjectQL;
7
+ let loader: ObjectLoader;
8
+
9
+ beforeEach(() => {
10
+ objectql = new ObjectQL({
11
+ datasources: {}
12
+ });
13
+ loader = new ObjectLoader(objectql.metadata);
14
+ });
15
+
16
+ test('should load directory manually', () => {
17
+ const fixtureDir = path.join(__dirname, 'fixtures');
18
+ loader.load(fixtureDir, 'test-pkg');
19
+
20
+ expect(objectql.getObject('project')).toBeDefined();
21
+ });
22
+
23
+ test('should remove package objects', () => {
24
+ const fixtureDir = path.join(__dirname, 'fixtures');
25
+ loader.load(fixtureDir, 'test-pkg');
26
+
27
+ expect(objectql.getObject('project')).toBeDefined();
28
+
29
+ objectql.removePackage('test-pkg');
30
+
31
+ expect(objectql.getObject('project')).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,124 @@
1
+ name: project
2
+ label: Project
3
+ description: Project object with validation rules
4
+
5
+ fields:
6
+ name:
7
+ type: text
8
+ label: Project Name
9
+ required: true
10
+ validation:
11
+ min_length: 3
12
+ max_length: 100
13
+ message: Project name must be between 3 and 100 characters
14
+ ai_context:
15
+ intent: Unique identifier for the project
16
+
17
+ description:
18
+ type: textarea
19
+ label: Description
20
+
21
+ status:
22
+ type: select
23
+ label: Status
24
+ required: true
25
+ defaultValue: planning
26
+ options:
27
+ - label: Planning
28
+ value: planning
29
+ - label: Active
30
+ value: active
31
+ - label: On Hold
32
+ value: on_hold
33
+ - label: Completed
34
+ value: completed
35
+ - label: Cancelled
36
+ value: cancelled
37
+ ai_context:
38
+ intent: Track project through its lifecycle
39
+ is_state_machine: true
40
+
41
+ budget:
42
+ type: currency
43
+ label: Budget
44
+ validation:
45
+ min: 0
46
+ max: 10000000
47
+ message: Budget must be between 0 and 10,000,000
48
+
49
+ start_date:
50
+ type: date
51
+ label: Start Date
52
+ required: true
53
+
54
+ end_date:
55
+ type: date
56
+ label: End Date
57
+
58
+ email:
59
+ type: email
60
+ label: Contact Email
61
+ validation:
62
+ format: email
63
+ message: Please enter a valid email address
64
+
65
+ website:
66
+ type: url
67
+ label: Website
68
+ validation:
69
+ format: url
70
+ protocols: [http, https]
71
+ message: Please enter a valid URL
72
+
73
+ validation:
74
+ ai_context:
75
+ intent: Ensure project data integrity and enforce business rules
76
+ validation_strategy: Fail fast with clear error messages
77
+
78
+ rules:
79
+ # Cross-field validation: End date must be after start date
80
+ - name: valid_date_range
81
+ type: cross_field
82
+ ai_context:
83
+ intent: Ensure timeline makes logical sense
84
+ business_rule: Projects cannot end before they start
85
+ error_impact: high
86
+ rule:
87
+ field: end_date
88
+ operator: ">="
89
+ compare_to: start_date
90
+ message: End date must be on or after start date
91
+ error_code: INVALID_DATE_RANGE
92
+
93
+ # State machine validation
94
+ - name: status_transition
95
+ type: state_machine
96
+ field: status
97
+ ai_context:
98
+ intent: Control valid status transitions throughout project lifecycle
99
+ business_rule: Projects follow a controlled workflow
100
+ transitions:
101
+ planning:
102
+ allowed_next: [active, cancelled]
103
+ ai_context:
104
+ rationale: Can start work or cancel before beginning
105
+ active:
106
+ allowed_next: [on_hold, completed, cancelled]
107
+ ai_context:
108
+ rationale: Can pause, finish, or cancel ongoing work
109
+ on_hold:
110
+ allowed_next: [active, cancelled]
111
+ ai_context:
112
+ rationale: Can resume or cancel paused projects
113
+ completed:
114
+ allowed_next: []
115
+ is_terminal: true
116
+ ai_context:
117
+ rationale: Finished projects cannot change state
118
+ cancelled:
119
+ allowed_next: []
120
+ is_terminal: true
121
+ ai_context:
122
+ rationale: Cancelled projects are final
123
+ message: "Invalid status transition from {{old_status}} to {{new_status}}"
124
+ error_code: INVALID_STATE_TRANSITION
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ listenTo: 'project',
3
+ closeProject: {
4
+ handler: async function(ctx) {
5
+ return { success: true };
6
+ }
7
+ }
8
+ };
@@ -0,0 +1,41 @@
1
+ name: project
2
+ label: Project
3
+ icon: standard:case
4
+ enable_search: true
5
+ fields:
6
+ name:
7
+ label: Project Name
8
+ type: text
9
+ required: true
10
+ searchable: true
11
+ index: true
12
+
13
+ status:
14
+ label: Status
15
+ type: select
16
+ options:
17
+ - label: Planned
18
+ value: planned
19
+ - label: In Progress
20
+ value: in_progress
21
+ - label: Completed
22
+ value: completed
23
+ defaultValue: planned
24
+
25
+ start_date:
26
+ label: Start Date
27
+ type: date
28
+
29
+ owner:
30
+ label: Project Manager
31
+ type: lookup
32
+ reference_to: users
33
+
34
+ budget:
35
+ label: Total Budget
36
+ type: currency
37
+ scale: 2
38
+
39
+ description:
40
+ label: Description
41
+ type: textarea