@object-ui/core 0.3.1 → 0.5.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.
Files changed (68) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/dist/actions/index.d.ts +1 -1
  3. package/dist/actions/index.js +1 -1
  4. package/dist/evaluator/ExpressionCache.d.ts +101 -0
  5. package/dist/evaluator/ExpressionCache.js +135 -0
  6. package/dist/evaluator/ExpressionEvaluator.d.ts +20 -2
  7. package/dist/evaluator/ExpressionEvaluator.js +34 -14
  8. package/dist/evaluator/index.d.ts +3 -2
  9. package/dist/evaluator/index.js +3 -2
  10. package/dist/index.d.ts +10 -7
  11. package/dist/index.js +9 -7
  12. package/dist/query/index.d.ts +6 -0
  13. package/dist/query/index.js +6 -0
  14. package/dist/query/query-ast.d.ts +32 -0
  15. package/dist/query/query-ast.js +268 -0
  16. package/dist/registry/PluginScopeImpl.d.ts +80 -0
  17. package/dist/registry/PluginScopeImpl.js +243 -0
  18. package/dist/registry/PluginSystem.d.ts +66 -0
  19. package/dist/registry/PluginSystem.js +142 -0
  20. package/dist/registry/Registry.d.ts +73 -4
  21. package/dist/registry/Registry.js +112 -7
  22. package/dist/validation/index.d.ts +9 -0
  23. package/dist/validation/index.js +9 -0
  24. package/dist/validation/validation-engine.d.ts +70 -0
  25. package/dist/validation/validation-engine.js +363 -0
  26. package/dist/validation/validators/index.d.ts +16 -0
  27. package/dist/validation/validators/index.js +16 -0
  28. package/dist/validation/validators/object-validation-engine.d.ts +118 -0
  29. package/dist/validation/validators/object-validation-engine.js +538 -0
  30. package/package.json +13 -5
  31. package/src/actions/index.ts +1 -1
  32. package/src/evaluator/ExpressionCache.ts +192 -0
  33. package/src/evaluator/ExpressionEvaluator.ts +33 -14
  34. package/src/evaluator/__tests__/ExpressionCache.test.ts +135 -0
  35. package/src/evaluator/index.ts +3 -2
  36. package/src/index.ts +10 -7
  37. package/src/query/__tests__/query-ast.test.ts +211 -0
  38. package/src/query/__tests__/window-functions.test.ts +275 -0
  39. package/src/query/index.ts +7 -0
  40. package/src/query/query-ast.ts +341 -0
  41. package/src/registry/PluginScopeImpl.ts +259 -0
  42. package/src/registry/PluginSystem.ts +161 -0
  43. package/src/registry/Registry.ts +125 -8
  44. package/src/registry/__tests__/PluginSystem.test.ts +226 -0
  45. package/src/registry/__tests__/Registry.test.ts +293 -0
  46. package/src/registry/__tests__/plugin-scope-integration.test.ts +283 -0
  47. package/src/validation/__tests__/object-validation-engine.test.ts +567 -0
  48. package/src/validation/__tests__/validation-engine.test.ts +102 -0
  49. package/src/validation/index.ts +10 -0
  50. package/src/validation/validation-engine.ts +461 -0
  51. package/src/validation/validators/index.ts +25 -0
  52. package/src/validation/validators/object-validation-engine.ts +722 -0
  53. package/tsconfig.tsbuildinfo +1 -1
  54. package/vitest.config.ts +2 -0
  55. package/src/adapters/index.d.ts +0 -8
  56. package/src/adapters/index.js +0 -10
  57. package/src/builder/schema-builder.d.ts +0 -294
  58. package/src/builder/schema-builder.js +0 -503
  59. package/src/index.d.ts +0 -13
  60. package/src/index.js +0 -16
  61. package/src/registry/Registry.d.ts +0 -56
  62. package/src/registry/Registry.js +0 -43
  63. package/src/types/index.d.ts +0 -19
  64. package/src/types/index.js +0 -8
  65. package/src/utils/filter-converter.d.ts +0 -57
  66. package/src/utils/filter-converter.js +0 -100
  67. package/src/validation/schema-validator.d.ts +0 -94
  68. package/src/validation/schema-validator.js +0 -278
@@ -0,0 +1,538 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Simple expression evaluator using a simple parser (no dynamic code execution)
10
+ *
11
+ * SECURITY: This implementation parses expressions into an AST and evaluates them
12
+ * without using eval() or new Function(). It supports:
13
+ * - Comparison operators: ==, !=, >, <, >=, <=
14
+ * - Logical operators: &&, ||, !
15
+ * - Property access: record.field, record['field']
16
+ * - Literals: true, false, null, numbers, strings
17
+ *
18
+ * LIMITATIONS:
19
+ * - Single comparison operator per expression (no chaining like a > b > c)
20
+ * - Simple escape sequence handling (doesn't handle escaped backslashes)
21
+ * - Field names in bracket notation cannot contain escaped quotes
22
+ *
23
+ * For more complex expressions, integrate a dedicated library like:
24
+ * - JSONLogic (jsonlogic.com)
25
+ * - filtrex
26
+ *
27
+ * @see https://github.com/objectstack-ai/objectui/blob/main/SECURITY_FIX_SUMMARY.md
28
+ */
29
+ class SimpleExpressionEvaluator {
30
+ evaluate(expression, context) {
31
+ try {
32
+ return this.evaluateSafeExpression(expression.trim(), context);
33
+ }
34
+ catch (error) {
35
+ console.error('Expression evaluation error:', error);
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Safely evaluate an expression without using dynamic code execution
41
+ */
42
+ evaluateSafeExpression(expr, context) {
43
+ // Handle boolean literals
44
+ if (expr === 'true')
45
+ return true;
46
+ if (expr === 'false')
47
+ return false;
48
+ if (expr === 'null')
49
+ return null;
50
+ // Handle string literals
51
+ if ((expr.startsWith('"') && expr.endsWith('"')) ||
52
+ (expr.startsWith("'") && expr.endsWith("'"))) {
53
+ return expr.slice(1, -1);
54
+ }
55
+ // Handle numeric literals
56
+ if (/^-?\d+(\.\d+)?$/.test(expr)) {
57
+ return parseFloat(expr);
58
+ }
59
+ // Handle logical NOT
60
+ if (expr.startsWith('!')) {
61
+ return !this.evaluateSafeExpression(expr.slice(1).trim(), context);
62
+ }
63
+ // Handle logical AND
64
+ if (expr.includes('&&')) {
65
+ const parts = this.splitOnOperator(expr, '&&');
66
+ return parts.every(part => this.evaluateSafeExpression(part, context));
67
+ }
68
+ // Handle logical OR
69
+ if (expr.includes('||')) {
70
+ const parts = this.splitOnOperator(expr, '||');
71
+ return parts.some(part => this.evaluateSafeExpression(part, context));
72
+ }
73
+ // Handle comparison operators
74
+ const comparisonMatch = expr.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
75
+ if (comparisonMatch) {
76
+ const [, left, op, right] = comparisonMatch;
77
+ const leftVal = this.evaluateSafeExpression(left.trim(), context);
78
+ const rightVal = this.evaluateSafeExpression(right.trim(), context);
79
+ switch (op) {
80
+ case '===':
81
+ return leftVal === rightVal;
82
+ case '==':
83
+ // Use loose equality for backward compatibility with existing expressions
84
+ // eslint-disable-next-line eqeqeq
85
+ return leftVal == rightVal;
86
+ case '!==':
87
+ return leftVal !== rightVal;
88
+ case '!=':
89
+ // Use loose inequality for backward compatibility with existing expressions
90
+ // eslint-disable-next-line eqeqeq
91
+ return leftVal != rightVal;
92
+ case '>': return leftVal > rightVal;
93
+ case '<': return leftVal < rightVal;
94
+ case '>=': return leftVal >= rightVal;
95
+ case '<=': return leftVal <= rightVal;
96
+ default: return false;
97
+ }
98
+ }
99
+ // Handle property access (e.g., record.field or context.field)
100
+ return this.getValueFromContext(expr, context);
101
+ }
102
+ /**
103
+ * Split expression on operator, respecting parentheses and quotes
104
+ */
105
+ splitOnOperator(expr, operator) {
106
+ const parts = [];
107
+ let current = '';
108
+ let depth = 0;
109
+ let inString = false;
110
+ let stringChar = '';
111
+ for (let i = 0; i < expr.length; i++) {
112
+ const char = expr[i];
113
+ const nextChar = expr[i + 1];
114
+ const prevChar = i > 0 ? expr[i - 1] : '';
115
+ // Handle string quotes, checking for escape sequences
116
+ if ((char === '"' || char === "'") && !inString) {
117
+ inString = true;
118
+ stringChar = char;
119
+ }
120
+ else if (char === stringChar && inString && prevChar !== '\\') {
121
+ // Only close string if quote is not escaped
122
+ inString = false;
123
+ }
124
+ if (!inString) {
125
+ if (char === '(')
126
+ depth++;
127
+ if (char === ')')
128
+ depth--;
129
+ if (depth === 0 && char === operator[0] && nextChar === operator[1]) {
130
+ parts.push(current.trim());
131
+ current = '';
132
+ i++; // Skip next character
133
+ continue;
134
+ }
135
+ }
136
+ current += char;
137
+ }
138
+ if (current) {
139
+ parts.push(current.trim());
140
+ }
141
+ return parts;
142
+ }
143
+ /**
144
+ * Get value from context by path (e.g., "record.age" or "age")
145
+ */
146
+ getValueFromContext(path, context) {
147
+ // Handle bracket notation: record['field']
148
+ const bracketMatch = path.match(/^(\w+)\['([^']+)'\]$/);
149
+ if (bracketMatch) {
150
+ const [, obj, field] = bracketMatch;
151
+ return context[obj]?.[field];
152
+ }
153
+ // Handle dot notation: record.field or just field
154
+ const parts = path.split('.');
155
+ let value = context;
156
+ for (const part of parts) {
157
+ if (value && typeof value === 'object' && part in value) {
158
+ value = value[part];
159
+ }
160
+ else {
161
+ // Try direct context access for simple identifiers
162
+ if (parts.length === 1 && part in context) {
163
+ return context[part];
164
+ }
165
+ return undefined;
166
+ }
167
+ }
168
+ return value;
169
+ }
170
+ }
171
+ /**
172
+ * Object-Level Validation Engine
173
+ * Implements ObjectStack Spec v0.7.1 validation framework
174
+ */
175
+ export class ObjectValidationEngine {
176
+ constructor(expressionEvaluator, uniquenessChecker) {
177
+ Object.defineProperty(this, "expressionEvaluator", {
178
+ enumerable: true,
179
+ configurable: true,
180
+ writable: true,
181
+ value: void 0
182
+ });
183
+ Object.defineProperty(this, "uniquenessChecker", {
184
+ enumerable: true,
185
+ configurable: true,
186
+ writable: true,
187
+ value: void 0
188
+ });
189
+ this.expressionEvaluator = expressionEvaluator || new SimpleExpressionEvaluator();
190
+ this.uniquenessChecker = uniquenessChecker;
191
+ }
192
+ /**
193
+ * Validate a record against a set of validation rules
194
+ */
195
+ async validateRecord(rules, context, event = 'insert') {
196
+ const results = [];
197
+ for (const rule of rules) {
198
+ // Check if rule is active
199
+ if (!rule.active) {
200
+ continue;
201
+ }
202
+ // Check if rule applies to this event
203
+ if (!rule.events.includes(event)) {
204
+ continue;
205
+ }
206
+ const result = await this.validateRule(rule, context);
207
+ if (!result.valid) {
208
+ results.push(result);
209
+ }
210
+ }
211
+ return results;
212
+ }
213
+ /**
214
+ * Validate a single rule
215
+ */
216
+ async validateRule(rule, context) {
217
+ switch (rule.type) {
218
+ case 'script':
219
+ return this.validateScript(rule, context);
220
+ case 'unique':
221
+ return this.validateUniqueness(rule, context);
222
+ case 'state_machine':
223
+ return this.validateStateMachine(rule, context);
224
+ case 'cross_field':
225
+ return this.validateCrossField(rule, context);
226
+ case 'async':
227
+ return this.validateAsync(rule, context);
228
+ case 'conditional':
229
+ return this.validateConditional(rule, context);
230
+ case 'format':
231
+ return this.validateFormat(rule, context);
232
+ case 'range':
233
+ return this.validateRange(rule, context);
234
+ default:
235
+ return {
236
+ valid: true,
237
+ message: `Unknown validation type: ${rule.type}`,
238
+ };
239
+ }
240
+ }
241
+ /**
242
+ * Validate script-based rule
243
+ */
244
+ validateScript(rule, context) {
245
+ try {
246
+ const result = this.expressionEvaluator.evaluate(rule.condition, context.record);
247
+ if (!result) {
248
+ return {
249
+ valid: false,
250
+ message: rule.message,
251
+ rule: rule.name,
252
+ severity: rule.severity,
253
+ };
254
+ }
255
+ return { valid: true };
256
+ }
257
+ catch (error) {
258
+ return {
259
+ valid: false,
260
+ message: `Script evaluation error: ${error}`,
261
+ rule: rule.name,
262
+ severity: 'error',
263
+ };
264
+ }
265
+ }
266
+ /**
267
+ * Validate uniqueness constraint
268
+ */
269
+ async validateUniqueness(rule, context) {
270
+ if (!this.uniquenessChecker) {
271
+ console.warn('Uniqueness checker not configured');
272
+ return { valid: true };
273
+ }
274
+ const values = {};
275
+ for (const field of rule.fields) {
276
+ values[field] = context.record[field];
277
+ }
278
+ const isUnique = await this.uniquenessChecker(rule.fields, values, rule.scope, context);
279
+ if (!isUnique) {
280
+ return {
281
+ valid: false,
282
+ message: rule.message,
283
+ rule: rule.name,
284
+ severity: rule.severity,
285
+ };
286
+ }
287
+ return { valid: true };
288
+ }
289
+ /**
290
+ * Validate state machine transitions
291
+ */
292
+ validateStateMachine(rule, context) {
293
+ const currentState = context.record[rule.stateField];
294
+ const previousState = context.oldRecord?.[rule.stateField];
295
+ // If no previous state (insert), allow any state
296
+ if (!previousState) {
297
+ return { valid: true };
298
+ }
299
+ // Check if transition is allowed
300
+ for (const transition of rule.transitions) {
301
+ const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from];
302
+ if (!fromStates.includes(previousState)) {
303
+ continue;
304
+ }
305
+ if (transition.to !== currentState) {
306
+ continue;
307
+ }
308
+ // Check condition if specified
309
+ if (transition.condition) {
310
+ const conditionMet = this.expressionEvaluator.evaluate(transition.condition, context.record);
311
+ if (!conditionMet) {
312
+ continue;
313
+ }
314
+ }
315
+ // Valid transition found
316
+ return { valid: true };
317
+ }
318
+ return {
319
+ valid: false,
320
+ message: rule.message || `Invalid state transition from ${previousState} to ${currentState}`,
321
+ rule: rule.name,
322
+ severity: rule.severity,
323
+ };
324
+ }
325
+ /**
326
+ * Validate cross-field constraints
327
+ */
328
+ validateCrossField(rule, context) {
329
+ try {
330
+ const result = this.expressionEvaluator.evaluate(rule.condition, context.record);
331
+ if (!result) {
332
+ return {
333
+ valid: false,
334
+ message: rule.message,
335
+ rule: rule.name,
336
+ severity: rule.severity,
337
+ };
338
+ }
339
+ return { valid: true };
340
+ }
341
+ catch (error) {
342
+ return {
343
+ valid: false,
344
+ message: `Cross-field validation error: ${error}`,
345
+ rule: rule.name,
346
+ severity: 'error',
347
+ };
348
+ }
349
+ }
350
+ /**
351
+ * Validate async/remote validation
352
+ */
353
+ async validateAsync(rule, context) {
354
+ try {
355
+ const method = rule.method || 'POST';
356
+ const response = await fetch(rule.endpoint, {
357
+ method,
358
+ headers: {
359
+ 'Content-Type': 'application/json',
360
+ },
361
+ body: method !== 'GET' ? JSON.stringify(context.record) : undefined,
362
+ });
363
+ const data = await response.json();
364
+ if (!data.valid) {
365
+ return {
366
+ valid: false,
367
+ message: data.message || rule.message,
368
+ rule: rule.name,
369
+ severity: rule.severity,
370
+ };
371
+ }
372
+ return { valid: true };
373
+ }
374
+ catch (error) {
375
+ return {
376
+ valid: false,
377
+ message: `Async validation error: ${error}`,
378
+ rule: rule.name,
379
+ severity: 'error',
380
+ };
381
+ }
382
+ }
383
+ /**
384
+ * Validate conditional rules
385
+ */
386
+ async validateConditional(rule, context) {
387
+ try {
388
+ const conditionMet = this.expressionEvaluator.evaluate(rule.condition, context.record);
389
+ if (!conditionMet) {
390
+ // Condition not met, validation passes
391
+ return { valid: true };
392
+ }
393
+ // Condition met, validate nested rules
394
+ for (const nestedRule of rule.rules) {
395
+ const result = await this.validateRule(nestedRule, context);
396
+ if (!result.valid) {
397
+ return result;
398
+ }
399
+ }
400
+ return { valid: true };
401
+ }
402
+ catch (error) {
403
+ return {
404
+ valid: false,
405
+ message: `Conditional validation error: ${error}`,
406
+ rule: rule.name,
407
+ severity: 'error',
408
+ };
409
+ }
410
+ }
411
+ /**
412
+ * Validate format/pattern
413
+ */
414
+ validateFormat(rule, context) {
415
+ const value = context.record[rule.field];
416
+ if (value === null || value === undefined || value === '') {
417
+ return { valid: true };
418
+ }
419
+ try {
420
+ let pattern;
421
+ if (rule.format) {
422
+ // Use predefined format
423
+ pattern = this.getPredefinedPattern(rule.format);
424
+ }
425
+ else if (typeof rule.pattern === 'string') {
426
+ pattern = new RegExp(rule.pattern, rule.flags);
427
+ }
428
+ else {
429
+ pattern = rule.pattern;
430
+ }
431
+ if (!pattern.test(String(value))) {
432
+ return {
433
+ valid: false,
434
+ message: rule.message || `Invalid format for ${rule.field}`,
435
+ rule: rule.name,
436
+ severity: rule.severity,
437
+ };
438
+ }
439
+ return { valid: true };
440
+ }
441
+ catch (error) {
442
+ return {
443
+ valid: false,
444
+ message: `Format validation error: ${error}`,
445
+ rule: rule.name,
446
+ severity: 'error',
447
+ };
448
+ }
449
+ }
450
+ /**
451
+ * Validate range constraints
452
+ */
453
+ validateRange(rule, context) {
454
+ const value = context.record[rule.field];
455
+ if (value === null || value === undefined) {
456
+ return { valid: true };
457
+ }
458
+ try {
459
+ // Convert to comparable values
460
+ let compareValue;
461
+ let minValue;
462
+ let maxValue;
463
+ if (value instanceof Date || typeof value === 'string') {
464
+ compareValue = value instanceof Date ? value : new Date(value);
465
+ minValue = rule.min ? (rule.min instanceof Date ? rule.min : new Date(rule.min)) : undefined;
466
+ maxValue = rule.max ? (rule.max instanceof Date ? rule.max : new Date(rule.max)) : undefined;
467
+ }
468
+ else {
469
+ compareValue = Number(value);
470
+ minValue = rule.min !== undefined ? Number(rule.min) : undefined;
471
+ maxValue = rule.max !== undefined ? Number(rule.max) : undefined;
472
+ }
473
+ // Check minimum
474
+ if (minValue !== undefined) {
475
+ const fails = rule.minExclusive
476
+ ? compareValue <= minValue
477
+ : compareValue < minValue;
478
+ if (fails) {
479
+ return {
480
+ valid: false,
481
+ message: rule.message || `Value must be ${rule.minExclusive ? 'greater than' : 'at least'} ${rule.min}`,
482
+ rule: rule.name,
483
+ severity: rule.severity,
484
+ };
485
+ }
486
+ }
487
+ // Check maximum
488
+ if (maxValue !== undefined) {
489
+ const fails = rule.maxExclusive
490
+ ? compareValue >= maxValue
491
+ : compareValue > maxValue;
492
+ if (fails) {
493
+ return {
494
+ valid: false,
495
+ message: rule.message || `Value must be ${rule.maxExclusive ? 'less than' : 'at most'} ${rule.max}`,
496
+ rule: rule.name,
497
+ severity: rule.severity,
498
+ };
499
+ }
500
+ }
501
+ return { valid: true };
502
+ }
503
+ catch (error) {
504
+ return {
505
+ valid: false,
506
+ message: `Range validation error: ${error}`,
507
+ rule: rule.name,
508
+ severity: 'error',
509
+ };
510
+ }
511
+ }
512
+ /**
513
+ * Get predefined regex pattern
514
+ */
515
+ getPredefinedPattern(format) {
516
+ const patterns = {
517
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
518
+ url: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)$/,
519
+ phone: /^[\d\s\-+()]+$/,
520
+ ipv4: /^(\d{1,3}\.){3}\d{1,3}$/,
521
+ ipv6: /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i,
522
+ uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
523
+ iso_date: /^\d{4}-\d{2}-\d{2}$/,
524
+ credit_card: /^\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}$/,
525
+ };
526
+ return patterns[format] || /.*/;
527
+ }
528
+ }
529
+ /**
530
+ * Default instance
531
+ */
532
+ export const defaultObjectValidationEngine = new ObjectValidationEngine();
533
+ /**
534
+ * Convenience function to validate a record
535
+ */
536
+ export async function validateRecord(rules, context, event = 'insert') {
537
+ return defaultObjectValidationEngine.validateRecord(rules, context, event);
538
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
+ "type": "module",
4
5
  "license": "MIT",
5
6
  "description": "Core logic, types, and validation for Object UI. Zero React dependencies.",
6
7
  "homepage": "https://www.objectui.org",
@@ -12,13 +13,20 @@
12
13
  "bugs": {
13
14
  "url": "https://github.com/objectstack-ai/objectui/issues"
14
15
  },
15
- "main": "dist/index.js",
16
- "types": "dist/index.d.ts",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
17
25
  "dependencies": {
18
- "@objectstack/spec": "^0.3.3",
26
+ "@objectstack/spec": "^0.9.1",
19
27
  "lodash": "^4.17.23",
20
28
  "zod": "^4.3.6",
21
- "@object-ui/types": "0.3.1"
29
+ "@object-ui/types": "0.5.0"
22
30
  },
23
31
  "devDependencies": {
24
32
  "typescript": "^5.9.3",
@@ -6,4 +6,4 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
- export * from './ActionRunner';
9
+ export * from './ActionRunner.js';