dzql 0.1.0-alpha.4 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.1",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -16,6 +16,7 @@
16
16
  "README.md",
17
17
  "GETTING_STARTED.md",
18
18
  "REFERENCE.md",
19
+ "CLAUDE.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "scripts": {
@@ -26,7 +27,6 @@
26
27
  "jose": "^6.1.0",
27
28
  "postgres": "^3.4.7"
28
29
  },
29
-
30
30
  "keywords": [
31
31
  "postgresql",
32
32
  "postgres",
@@ -62,7 +62,6 @@
62
62
  "bun": ">=1.0.0"
63
63
  },
64
64
  "publishConfig": {
65
- "access": "public",
66
- "tag": "alpha"
65
+ "access": "public"
67
66
  }
68
67
  }
package/src/client/ws.js CHANGED
@@ -1,5 +1,58 @@
1
+ /**
2
+ * WebSocket manager for DZQL client-side real-time communication
3
+ *
4
+ * Provides:
5
+ * - WebSocket connection management with auto-reconnect
6
+ * - JSON-RPC 2.0 protocol for API calls
7
+ * - Proxy-based API matching server-side db.api pattern
8
+ * - Real-time broadcast event handling
9
+ * - Automatic JWT authentication
10
+ *
11
+ * @class WebSocketManager
12
+ *
13
+ * @example
14
+ * // Basic usage
15
+ * import { WebSocketManager } from 'dzql/client';
16
+ *
17
+ * const ws = new WebSocketManager();
18
+ * await ws.connect('ws://localhost:3000/ws');
19
+ *
20
+ * // Login
21
+ * const session = await ws.api.login_user({
22
+ * email: 'user@example.com',
23
+ * password: 'password123'
24
+ * });
25
+ *
26
+ * // CRUD operations
27
+ * const venue = await ws.api.get.venues({ id: 1 });
28
+ * const created = await ws.api.save.venues({ name: 'New Venue' });
29
+ *
30
+ * // Listen to real-time updates
31
+ * ws.onBroadcast((method, params) => {
32
+ * console.log(`Event: ${method}`, params);
33
+ * });
34
+ *
35
+ * @example
36
+ * // Advanced search
37
+ * const results = await ws.api.search.venues({
38
+ * filters: {
39
+ * city: 'New York',
40
+ * capacity: { gte: 1000 },
41
+ * _search: 'garden'
42
+ * },
43
+ * sort: { field: 'name', order: 'asc' },
44
+ * page: 1,
45
+ * limit: 25
46
+ * });
47
+ */
1
48
  // Pure WebSocket manager class (no React dependencies)
2
49
  class WebSocketManager {
50
+ /**
51
+ * Create a WebSocketManager instance
52
+ *
53
+ * @param {Object} [options={}] - Configuration options
54
+ * @param {number} [options.maxReconnectAttempts=5] - Maximum reconnection attempts before giving up
55
+ */
3
56
  constructor(options = {}) {
4
57
  this.ws = null;
5
58
  this.messageId = 0;
@@ -22,6 +75,62 @@ class WebSocketManager {
22
75
  search: this.createEntityProxy("search"),
23
76
  };
24
77
 
78
+ /**
79
+ * API proxy for calling DZQL operations and custom functions
80
+ *
81
+ * @member {Object} api
82
+ * @memberof WebSocketManager
83
+ *
84
+ * @property {Object} get - Get single record by primary key
85
+ * @property {Object} save - Create or update record (upsert)
86
+ * @property {Object} delete - Delete record by primary key
87
+ * @property {Object} lookup - Autocomplete lookup by label field
88
+ * @property {Object} search - Advanced search with filters
89
+ *
90
+ * @example
91
+ * // Get operation
92
+ * const venue = await ws.api.get.venues({ id: 1 });
93
+ *
94
+ * @example
95
+ * // Save operation (create)
96
+ * const created = await ws.api.save.venues({
97
+ * name: 'New Venue',
98
+ * org_id: 3
99
+ * });
100
+ *
101
+ * @example
102
+ * // Save operation (update)
103
+ * const updated = await ws.api.save.venues({
104
+ * id: 1,
105
+ * name: 'Updated Name'
106
+ * });
107
+ *
108
+ * @example
109
+ * // Delete operation
110
+ * await ws.api.delete.venues({ id: 1 });
111
+ *
112
+ * @example
113
+ * // Lookup for autocomplete
114
+ * const results = await ws.api.lookup.venues({ p_filter: 'garden' });
115
+ *
116
+ * @example
117
+ * // Search with filters
118
+ * const results = await ws.api.search.venues({
119
+ * filters: {
120
+ * city: 'New York',
121
+ * capacity: { gte: 1000, lt: 5000 },
122
+ * name: { ilike: '%garden%' },
123
+ * _search: 'madison'
124
+ * },
125
+ * sort: { field: 'name', order: 'asc' },
126
+ * page: 1,
127
+ * limit: 25
128
+ * });
129
+ *
130
+ * @example
131
+ * // Call custom function
132
+ * const result = await ws.api.myCustomFunction({ param: 'value' });
133
+ */
25
134
  this.api = new Proxy(dzqlOps, {
26
135
  get: (target, prop) => {
27
136
  // Return cached DZQL operation if it exists
@@ -102,6 +211,31 @@ class WebSocketManager {
102
211
  );
103
212
  }
104
213
 
214
+ /**
215
+ * Connect to DZQL WebSocket server
216
+ *
217
+ * Automatically detects environment (browser vs Node.js) and constructs WebSocket URL.
218
+ * If JWT token exists in localStorage, automatically includes it in connection.
219
+ *
220
+ * @param {string|null} [url=null] - WebSocket URL (auto-detected if not provided)
221
+ * Browser: ws://current-host/ws or wss://current-host/ws
222
+ * Node.js: ws://localhost:3000/ws
223
+ * @param {number} [timeout=5000] - Connection timeout in milliseconds
224
+ *
225
+ * @returns {Promise<void>} Resolves when connected, rejects on timeout or error
226
+ *
227
+ * @example
228
+ * // Auto-detect URL (browser)
229
+ * await ws.connect();
230
+ *
231
+ * @example
232
+ * // Explicit URL
233
+ * await ws.connect('ws://localhost:3000/ws');
234
+ *
235
+ * @example
236
+ * // Custom timeout
237
+ * await ws.connect(null, 10000); // 10 second timeout
238
+ */
105
239
  connect(url = null, timeout = 5000) {
106
240
  return new Promise((resolve, reject) => {
107
241
  let wsUrl;
@@ -214,6 +348,14 @@ class WebSocketManager {
214
348
  }
215
349
  }
216
350
 
351
+ /**
352
+ * Call a method via JSON-RPC over WebSocket
353
+ *
354
+ * @private
355
+ * @param {string} method - Method name (e.g., 'login_user' or 'dzql.get.venues')
356
+ * @param {Object} [params={}] - Method parameters
357
+ * @returns {Promise<*>} Resolves with method result, rejects on error
358
+ */
217
359
  call(method, params = {}) {
218
360
  return new Promise((resolve, reject) => {
219
361
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -234,6 +376,50 @@ class WebSocketManager {
234
376
  });
235
377
  }
236
378
 
379
+ /**
380
+ * Register callback for real-time broadcast events
381
+ *
382
+ * Broadcasts are sent when data changes (insert/update/delete operations).
383
+ * Method format: "{table}:{operation}" (e.g., "venues:update")
384
+ *
385
+ * @param {Function} callback - Callback function (method, params) => void
386
+ * @returns {Function} Cleanup function to remove the callback
387
+ *
388
+ * @example
389
+ * // Listen to all broadcasts
390
+ * ws.onBroadcast((method, params) => {
391
+ * console.log(`Event: ${method}`, params);
392
+ * });
393
+ *
394
+ * @example
395
+ * // Listen to specific table events
396
+ * ws.onBroadcast((method, params) => {
397
+ * if (method === 'venues:update') {
398
+ * console.log('Venue updated:', params.after);
399
+ * }
400
+ * });
401
+ *
402
+ * @example
403
+ * // With cleanup
404
+ * const cleanup = ws.onBroadcast((method, params) => {
405
+ * console.log(method, params);
406
+ * });
407
+ *
408
+ * // Later: stop listening
409
+ * cleanup();
410
+ *
411
+ * @example
412
+ * // Event structure
413
+ * {
414
+ * table: 'venues',
415
+ * op: 'insert', // 'insert', 'update', or 'delete'
416
+ * pk: { id: 1 },
417
+ * before: null, // Old values (null for insert)
418
+ * after: { ... }, // New values (null for delete)
419
+ * user_id: 123,
420
+ * at: '2025-01-15T10:30:00Z'
421
+ * }
422
+ */
237
423
  onBroadcast(callback) {
238
424
  this.broadcastCallbacks.add(callback);
239
425
  return () => this.broadcastCallbacks.delete(callback);
@@ -0,0 +1,398 @@
1
+ -- Graph Rules Extensions: Validation and Custom Function Execution
2
+ -- Adds support for 'validate' and 'execute' action types in graph rules
3
+
4
+ -- === Condition Evaluation ===
5
+ -- Evaluates a condition string with variable substitution
6
+ CREATE OR REPLACE FUNCTION dzql.evaluate_condition(
7
+ p_condition text,
8
+ p_record_before jsonb,
9
+ p_record_after jsonb,
10
+ p_user_id int
11
+ ) RETURNS boolean
12
+ LANGUAGE plpgsql AS $$
13
+ DECLARE
14
+ l_resolved_condition text;
15
+ l_result boolean;
16
+ l_match_array text[];
17
+ l_field_name text;
18
+ l_field_value text;
19
+ BEGIN
20
+ -- Replace variables in condition
21
+ l_resolved_condition := p_condition;
22
+
23
+ -- Replace @before.field references
24
+ IF p_record_before IS NOT NULL THEN
25
+ LOOP
26
+ l_match_array := regexp_match(l_resolved_condition, '@before\.(\w+)');
27
+ EXIT WHEN l_match_array IS NULL;
28
+
29
+ l_field_name := l_match_array[1];
30
+ l_field_value := COALESCE(p_record_before->>l_field_name, 'null');
31
+ l_resolved_condition := regexp_replace(
32
+ l_resolved_condition,
33
+ '@before\.' || l_field_name,
34
+ quote_literal(l_field_value),
35
+ 1
36
+ );
37
+ END LOOP;
38
+ END IF;
39
+
40
+ -- Replace @after.field references
41
+ IF p_record_after IS NOT NULL THEN
42
+ LOOP
43
+ l_match_array := regexp_match(l_resolved_condition, '@after\.(\w+)');
44
+ EXIT WHEN l_match_array IS NULL;
45
+
46
+ l_field_name := l_match_array[1];
47
+ l_field_value := COALESCE(p_record_after->>l_field_name, 'null');
48
+ l_resolved_condition := regexp_replace(
49
+ l_resolved_condition,
50
+ '@after\.' || l_field_name,
51
+ quote_literal(l_field_value),
52
+ 1
53
+ );
54
+ END LOOP;
55
+ END IF;
56
+
57
+ -- Replace @user_id
58
+ l_resolved_condition := replace(l_resolved_condition, '@user_id', p_user_id::text);
59
+
60
+ -- Replace @id (use after if available, otherwise before)
61
+ IF p_record_after IS NOT NULL THEN
62
+ l_resolved_condition := replace(l_resolved_condition, '@id',
63
+ COALESCE(p_record_after->>'id', 'null'));
64
+ ELSIF p_record_before IS NOT NULL THEN
65
+ l_resolved_condition := replace(l_resolved_condition, '@id',
66
+ COALESCE(p_record_before->>'id', 'null'));
67
+ END IF;
68
+
69
+ -- Execute the condition
70
+ BEGIN
71
+ EXECUTE 'SELECT (' || l_resolved_condition || ')::boolean' INTO l_result;
72
+ RETURN COALESCE(l_result, false);
73
+ EXCEPTION WHEN OTHERS THEN
74
+ -- If condition evaluation fails, log and return false
75
+ RAISE WARNING 'Graph rule condition evaluation failed: % (condition: %)', SQLERRM, l_resolved_condition;
76
+ RETURN false;
77
+ END;
78
+ END $$;
79
+
80
+ -- === Validate Action ===
81
+ -- Calls a validation function and raises exception if it returns false
82
+ CREATE OR REPLACE FUNCTION dzql.execute_graph_validate(
83
+ p_function_name text,
84
+ p_params jsonb,
85
+ p_error_message text DEFAULT 'Validation failed'
86
+ ) RETURNS void
87
+ LANGUAGE plpgsql AS $$
88
+ DECLARE
89
+ l_result boolean;
90
+ l_sql text;
91
+ l_param_list text[];
92
+ l_key text;
93
+ l_value text;
94
+ BEGIN
95
+ -- Validate function name (prevent SQL injection)
96
+ IF NOT p_function_name ~ '^[a-z_][a-z0-9_]*$' THEN
97
+ RAISE EXCEPTION 'Invalid function name: %', p_function_name;
98
+ END IF;
99
+
100
+ -- Build parameter list
101
+ l_param_list := array[]::text[];
102
+ FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_params)
103
+ LOOP
104
+ l_param_list := l_param_list || (l_key || ' => ' || quote_literal(l_value));
105
+ END LOOP;
106
+
107
+ -- Build and execute function call
108
+ IF array_length(l_param_list, 1) > 0 THEN
109
+ l_sql := format('SELECT %I(%s)', p_function_name, array_to_string(l_param_list, ', '));
110
+ ELSE
111
+ l_sql := format('SELECT %I()', p_function_name);
112
+ END IF;
113
+
114
+ EXECUTE l_sql INTO l_result;
115
+
116
+ -- Raise exception if validation failed
117
+ IF NOT COALESCE(l_result, false) THEN
118
+ RAISE EXCEPTION '%', p_error_message;
119
+ END IF;
120
+ END $$;
121
+
122
+ -- === Execute Action ===
123
+ -- Calls a custom function with parameters (fire-and-forget)
124
+ CREATE OR REPLACE FUNCTION dzql.execute_graph_function(
125
+ p_function_name text,
126
+ p_params jsonb
127
+ ) RETURNS jsonb
128
+ LANGUAGE plpgsql AS $$
129
+ DECLARE
130
+ l_result jsonb;
131
+ l_sql text;
132
+ l_param_list text[];
133
+ l_key text;
134
+ l_value text;
135
+ BEGIN
136
+ -- Validate function name (prevent SQL injection)
137
+ IF NOT p_function_name ~ '^[a-z_][a-z0-9_]*$' THEN
138
+ RAISE EXCEPTION 'Invalid function name: %', p_function_name;
139
+ END IF;
140
+
141
+ -- Build parameter list
142
+ l_param_list := array[]::text[];
143
+ FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_params)
144
+ LOOP
145
+ l_param_list := l_param_list || (l_key || ' => ' || quote_literal(l_value));
146
+ END LOOP;
147
+
148
+ -- Build and execute function call
149
+ IF array_length(l_param_list, 1) > 0 THEN
150
+ l_sql := format('SELECT %I(%s)', p_function_name, array_to_string(l_param_list, ', '));
151
+ ELSE
152
+ l_sql := format('SELECT %I()', p_function_name);
153
+ END IF;
154
+
155
+ EXECUTE l_sql INTO l_result;
156
+
157
+ RETURN COALESCE(l_result, '{}'::jsonb);
158
+ EXCEPTION WHEN OTHERS THEN
159
+ -- Log error but don't fail the transaction
160
+ RAISE WARNING 'Graph rule function execution failed: % (function: %)', SQLERRM, p_function_name;
161
+ RETURN jsonb_build_object('error', SQLERRM);
162
+ END $$;
163
+
164
+ -- === Update execute_graph_rules to support new action types ===
165
+ CREATE OR REPLACE FUNCTION dzql.execute_graph_rules(
166
+ p_table_name text,
167
+ p_operation text, -- 'insert', 'update', 'delete'
168
+ p_record_before jsonb,
169
+ p_record_after jsonb,
170
+ p_user_id int
171
+ ) RETURNS jsonb
172
+ LANGUAGE plpgsql AS $$
173
+ DECLARE
174
+ l_entity_config record;
175
+ l_graph_rules jsonb;
176
+ l_trigger_key text;
177
+ l_trigger_rules jsonb;
178
+ l_rule_name text;
179
+ l_rule_config jsonb;
180
+ l_action jsonb;
181
+ l_action_type text;
182
+ l_target_entity text;
183
+ l_action_data jsonb;
184
+ l_action_match jsonb;
185
+ l_resolved_data jsonb;
186
+ l_resolved_match jsonb;
187
+ l_execution_log jsonb := '[]'::jsonb;
188
+ l_condition text;
189
+ l_condition_result boolean;
190
+ l_function_name text;
191
+ l_function_params jsonb;
192
+ l_error_message text;
193
+ l_function_result jsonb;
194
+ BEGIN
195
+ -- Get entity configuration
196
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_table_name;
197
+
198
+ IF l_entity_config IS NULL THEN
199
+ RETURN jsonb_build_object('status', 'entity_not_found');
200
+ END IF;
201
+
202
+ l_graph_rules := l_entity_config.graph_rules;
203
+
204
+ -- Early exit if no graph rules
205
+ IF l_graph_rules IS NULL OR l_graph_rules = '{}' THEN
206
+ RETURN jsonb_build_object('status', 'no_rules');
207
+ END IF;
208
+
209
+ -- Map operation to trigger key
210
+ l_trigger_key := CASE p_operation
211
+ WHEN 'insert' THEN 'on_create'
212
+ WHEN 'update' THEN 'on_update'
213
+ WHEN 'delete' THEN 'on_delete'
214
+ ELSE NULL
215
+ END;
216
+
217
+ IF l_trigger_key IS NULL THEN
218
+ RETURN jsonb_build_object('status', 'invalid_operation', 'operation', p_operation);
219
+ END IF;
220
+
221
+ -- Get rules for this trigger
222
+ l_trigger_rules := l_graph_rules->l_trigger_key;
223
+
224
+ IF l_trigger_rules IS NULL OR l_trigger_rules = '{}' THEN
225
+ RETURN jsonb_build_object('status', 'no_rules_for_trigger', 'trigger', l_trigger_key);
226
+ END IF;
227
+
228
+ -- Execute each rule
229
+ FOR l_rule_name, l_rule_config IN SELECT * FROM jsonb_each(l_trigger_rules)
230
+ LOOP
231
+ -- Check condition if present
232
+ l_condition := l_rule_config->>'condition';
233
+ l_condition_result := true; -- Default to true if no condition
234
+
235
+ IF l_condition IS NOT NULL THEN
236
+ l_condition_result := dzql.evaluate_condition(l_condition, p_record_before, p_record_after, p_user_id);
237
+ END IF;
238
+
239
+ IF l_condition_result THEN
240
+ -- Execute each action in the rule
241
+ FOR l_action IN SELECT * FROM jsonb_array_elements(l_rule_config->'actions')
242
+ LOOP
243
+ l_action_type := l_action->>'type';
244
+
245
+ -- Execute the action based on type
246
+ BEGIN
247
+ CASE l_action_type
248
+ -- Existing action types
249
+ WHEN 'create' THEN
250
+ l_target_entity := l_action->>'entity';
251
+ l_action_data := l_action->'data';
252
+ l_resolved_data := dzql.resolve_graph_data(l_action_data, p_record_before, p_record_after, p_user_id);
253
+ PERFORM dzql.execute_graph_insert(l_target_entity, l_resolved_data, p_user_id);
254
+
255
+ WHEN 'update' THEN
256
+ l_target_entity := l_action->>'entity';
257
+ l_action_data := l_action->'data';
258
+ l_action_match := l_action->'match';
259
+ l_resolved_data := dzql.resolve_graph_data(l_action_data, p_record_before, p_record_after, p_user_id);
260
+ l_resolved_match := dzql.resolve_graph_data(l_action_match, p_record_before, p_record_after, p_user_id);
261
+ PERFORM dzql.execute_graph_update(l_target_entity, l_resolved_match, l_resolved_data, p_user_id);
262
+
263
+ WHEN 'delete' THEN
264
+ l_target_entity := l_action->>'entity';
265
+ l_action_match := l_action->'match';
266
+ l_resolved_match := dzql.resolve_graph_data(l_action_match, p_record_before, p_record_after, p_user_id);
267
+ PERFORM dzql.execute_graph_delete(l_target_entity, l_resolved_match, p_user_id);
268
+
269
+ -- NEW: Validation action
270
+ WHEN 'validate' THEN
271
+ l_function_name := l_action->>'function';
272
+ l_function_params := l_action->'params';
273
+ l_error_message := COALESCE(l_action->>'error_message', 'Validation failed');
274
+ l_resolved_data := dzql.resolve_graph_data(l_function_params, p_record_before, p_record_after, p_user_id);
275
+ PERFORM dzql.execute_graph_validate(l_function_name, l_resolved_data, l_error_message);
276
+
277
+ -- NEW: Execute function action
278
+ WHEN 'execute' THEN
279
+ l_function_name := l_action->>'function';
280
+ l_function_params := l_action->'params';
281
+ l_resolved_data := dzql.resolve_graph_data(l_function_params, p_record_before, p_record_after, p_user_id);
282
+ l_function_result := dzql.execute_graph_function(l_function_name, l_resolved_data);
283
+
284
+ ELSE
285
+ RAISE WARNING 'Unknown graph rule action type: %', l_action_type;
286
+ END CASE;
287
+
288
+ -- Log successful execution
289
+ l_execution_log := l_execution_log || jsonb_build_object(
290
+ 'rule', l_rule_name,
291
+ 'action', l_action_type,
292
+ 'status', 'success'
293
+ );
294
+
295
+ EXCEPTION WHEN OTHERS THEN
296
+ -- Log error and re-raise for validate actions, otherwise just log
297
+ l_execution_log := l_execution_log || jsonb_build_object(
298
+ 'rule', l_rule_name,
299
+ 'action', l_action_type,
300
+ 'status', 'error',
301
+ 'error', SQLERRM
302
+ );
303
+
304
+ -- Re-raise exceptions from validate actions to prevent operation
305
+ IF l_action_type = 'validate' THEN
306
+ RAISE;
307
+ END IF;
308
+ END;
309
+ END LOOP;
310
+ END IF;
311
+ END LOOP;
312
+
313
+ RETURN jsonb_build_object(
314
+ 'status', 'success',
315
+ 'trigger', l_trigger_key,
316
+ 'executed_actions', l_execution_log
317
+ );
318
+ END $$;
319
+
320
+ -- === Update validate_graph_rules to accept new action types ===
321
+ CREATE OR REPLACE FUNCTION dzql.validate_graph_rules(
322
+ p_rules jsonb
323
+ ) RETURNS boolean
324
+ LANGUAGE plpgsql AS $$
325
+ DECLARE
326
+ l_trigger_key text;
327
+ l_trigger_rules jsonb;
328
+ l_rule_name text;
329
+ l_rule_config jsonb;
330
+ l_action jsonb;
331
+ l_action_type text;
332
+ BEGIN
333
+ -- Check if rules is empty or null (valid)
334
+ IF p_rules IS NULL OR p_rules = '{}' THEN
335
+ RETURN true;
336
+ END IF;
337
+
338
+ -- Validate top-level trigger types
339
+ FOR l_trigger_key, l_trigger_rules IN SELECT * FROM jsonb_each(p_rules)
340
+ LOOP
341
+ -- Check valid trigger types
342
+ IF l_trigger_key NOT IN ('on_create', 'on_update', 'on_delete', 'on_field_change') THEN
343
+ RAISE WARNING 'Invalid trigger type: %', l_trigger_key;
344
+ RETURN false;
345
+ END IF;
346
+
347
+ -- Validate each rule within the trigger
348
+ FOR l_rule_name, l_rule_config IN SELECT * FROM jsonb_each(l_trigger_rules)
349
+ LOOP
350
+ -- Check required fields
351
+ IF NOT l_rule_config ? 'actions' THEN
352
+ RAISE WARNING 'Rule % missing required "actions" field', l_rule_name;
353
+ RETURN false;
354
+ END IF;
355
+
356
+ -- Validate each action
357
+ FOR l_action IN SELECT * FROM jsonb_array_elements(l_rule_config->'actions')
358
+ LOOP
359
+ l_action_type := l_action->>'type';
360
+
361
+ -- Check valid action types (includes new 'validate' and 'execute' types)
362
+ IF l_action_type NOT IN ('create', 'update', 'delete', 'validate', 'execute') THEN
363
+ RAISE WARNING 'Invalid action type: %', l_action_type;
364
+ RETURN false;
365
+ END IF;
366
+
367
+ -- Check required fields per action type
368
+ IF l_action_type IN ('create', 'update') AND NOT l_action ? 'entity' THEN
369
+ RAISE WARNING 'Action type % requires "entity" field', l_action_type;
370
+ RETURN false;
371
+ END IF;
372
+
373
+ IF l_action_type = 'create' AND NOT l_action ? 'data' THEN
374
+ RAISE WARNING 'Action type "create" requires "data" field';
375
+ RETURN false;
376
+ END IF;
377
+
378
+ IF l_action_type IN ('update', 'delete') AND NOT l_action ? 'match' THEN
379
+ RAISE WARNING 'Action type % requires "match" field', l_action_type;
380
+ RETURN false;
381
+ END IF;
382
+
383
+ -- Validate new action types
384
+ IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'function' THEN
385
+ RAISE WARNING 'Action type % requires "function" field', l_action_type;
386
+ RETURN false;
387
+ END IF;
388
+
389
+ IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'params' THEN
390
+ RAISE WARNING 'Action type % requires "params" field', l_action_type;
391
+ RETURN false;
392
+ END IF;
393
+ END LOOP;
394
+ END LOOP;
395
+ END LOOP;
396
+
397
+ RETURN true;
398
+ END $$;