dzql 0.5.23 → 0.5.25

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/docs/README.md CHANGED
@@ -16,6 +16,7 @@ Feature-specific guides and how-tos:
16
16
 
17
17
  - **[Live Query Subscriptions](guides/subscriptions.md)** - Real-time denormalized documents
18
18
  - **[Many-to-Many Relationships](guides/many-to-many.md)** - Junction table management
19
+ - **[Composite Primary Keys](guides/composite-primary-keys.md)** - Tables with compound keys
19
20
  - **[Field Defaults](guides/field-defaults.md)** - Auto-populate fields on create
20
21
  - **[Custom Functions](guides/custom-functions.md)** - Extend with PostgreSQL or Bun functions
21
22
  - **[Client Stores](guides/client-stores.md)** - Pinia store patterns for Vue.js
@@ -23,10 +23,16 @@ Entity Registration:
23
23
  temporal_fields, -- '{}'
24
24
  notification_paths, -- '{"ownership": ["@org_id->acts_for..."]}'
25
25
  permission_paths, -- '{"view": [], "create": [...]}'
26
- graph_rules, -- '{"on_create": {...}, "many_to_many": {...}}'
26
+ graph_rules, -- '{"on_create": {...}, "many_to_many": {...}, "primary_key": [...]}'
27
27
  field_defaults -- '{"owner_id": "@user_id"}'
28
28
  )
29
29
 
30
+ Composite Primary Keys:
31
+ graph_rules: '{"primary_key": ["entity_type", "entity_id"]}'
32
+ - GET/DELETE accept JSONB: get_table(user_id, '{"col1": "val", "col2": 123}')
33
+ - SAVE detects insert/update by checking if all PK fields exist
34
+ - Columns ending with _id are cast to ::int, others stay text
35
+
30
36
  M2M id_field naming: tag_ids (singular + _ids), NOT tags_ids
31
37
  Permission [] = public, omitted = denied
32
38
  Path syntax: @field->table[filter]{temporal}.target_field
@@ -0,0 +1,158 @@
1
+ # Composite Primary Keys
2
+
3
+ DZQL supports tables with composite (compound) primary keys. This guide explains how to register entities with composite keys and how the generated CRUD functions work.
4
+
5
+ ## When to Use Composite Keys
6
+
7
+ Composite primary keys are useful for:
8
+
9
+ - **Junction tables** with additional data (beyond simple M2M)
10
+ - **Position/state tables** keyed by entity type and ID
11
+ - **Multi-tenant tables** keyed by tenant + entity
12
+ - **Versioned records** keyed by ID + version
13
+
14
+ ## Registering an Entity with a Composite Key
15
+
16
+ To register an entity with a composite primary key, add `primary_key` to the `graph_rules` parameter:
17
+
18
+ ```sql
19
+ -- Create a table with composite primary key
20
+ CREATE TABLE canvas_positions (
21
+ entity_type VARCHAR(50) NOT NULL,
22
+ entity_id INTEGER NOT NULL,
23
+ x INTEGER NOT NULL,
24
+ y INTEGER NOT NULL,
25
+ width INTEGER DEFAULT 100,
26
+ height INTEGER DEFAULT 100,
27
+ PRIMARY KEY (entity_type, entity_id)
28
+ );
29
+
30
+ -- Register with DZQL using composite key
31
+ SELECT dzql.register_entity(
32
+ 'canvas_positions', -- table_name
33
+ 'entity_type', -- label_field
34
+ array['x', 'y', 'width', 'height'], -- searchable_fields
35
+ '{}', -- fk_includes
36
+ false, -- soft_delete
37
+ '{}', -- temporal_fields
38
+ '{}', -- notification_paths
39
+ jsonb_build_object( -- permission_paths
40
+ 'view', array[]::text[],
41
+ 'create', array[]::text[],
42
+ 'update', array[]::text[],
43
+ 'delete', array[]::text[]
44
+ ),
45
+ jsonb_build_object( -- graph_rules
46
+ 'primary_key', array['entity_type', 'entity_id']
47
+ )
48
+ );
49
+ ```
50
+
51
+ The `primary_key` array specifies the columns that form the composite key, in order.
52
+
53
+ ## Generated Function Signatures
54
+
55
+ When you compile an entity with a composite primary key, the generated functions have different signatures:
56
+
57
+ ### GET Function
58
+
59
+ ```sql
60
+ -- Accepts JSONB with all PK fields
61
+ SELECT get_canvas_positions(
62
+ 1, -- user_id
63
+ '{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
64
+ );
65
+ ```
66
+
67
+ ### SAVE Function
68
+
69
+ ```sql
70
+ -- Insert: provide all PK fields plus data
71
+ SELECT save_canvas_positions(
72
+ 1, -- user_id
73
+ '{"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}'
74
+ );
75
+
76
+ -- Update: same signature, existing record detected by PK
77
+ SELECT save_canvas_positions(
78
+ 1,
79
+ '{"entity_type": "node", "entity_id": 42, "x": 150, "y": 250}'
80
+ );
81
+ ```
82
+
83
+ The save function determines insert vs update by checking if a record with the composite key exists.
84
+
85
+ ### DELETE Function
86
+
87
+ ```sql
88
+ -- Accepts JSONB with all PK fields
89
+ SELECT delete_canvas_positions(
90
+ 1, -- user_id
91
+ '{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
92
+ );
93
+ ```
94
+
95
+ ### SEARCH Function
96
+
97
+ Search works the same as simple PK entities - it returns paginated results with all fields.
98
+
99
+ ## Type Casting
100
+
101
+ DZQL automatically determines type casting for PK columns:
102
+
103
+ - Columns named `id` or ending with `_id` are cast to `::int`
104
+ - Other columns (like `entity_type`) are left as text
105
+
106
+ This means for a key like `(entity_type, entity_id)`:
107
+ - `entity_type` is compared as text
108
+ - `entity_id` is cast to integer
109
+
110
+ ## Events and Notifications
111
+
112
+ Events for composite PK entities include the full composite key in the `pk` field:
113
+
114
+ ```json
115
+ {
116
+ "table_name": "canvas_positions",
117
+ "op": "insert",
118
+ "pk": {"entity_type": "node", "entity_id": 42},
119
+ "data": {"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}
120
+ }
121
+ ```
122
+
123
+ ## Limitations
124
+
125
+ - **M2M relationships**: Tables with composite PKs can have M2M relationships, but this is an advanced use case. The M2M sync uses the first PK column for junction table lookups.
126
+ - **Auto-increment**: Composite keys don't support auto-increment. All PK values must be provided on insert.
127
+
128
+ ## Example: Template Dependencies
129
+
130
+ A practical example - tracking dependencies between templates:
131
+
132
+ ```sql
133
+ CREATE TABLE template_dependencies (
134
+ template_id INTEGER NOT NULL REFERENCES templates(id),
135
+ depends_on_template_id INTEGER NOT NULL REFERENCES templates(id),
136
+ dependency_type VARCHAR(20) DEFAULT 'requires',
137
+ PRIMARY KEY (template_id, depends_on_template_id)
138
+ );
139
+
140
+ SELECT dzql.register_entity(
141
+ 'template_dependencies',
142
+ 'dependency_type',
143
+ array['dependency_type'],
144
+ '{"template": "templates", "depends_on": "templates"}',
145
+ false,
146
+ '{}',
147
+ '{}',
148
+ jsonb_build_object(
149
+ 'view', array[]::text[],
150
+ 'create', array[]::text[],
151
+ 'update', array[]::text[],
152
+ 'delete', array[]::text[]
153
+ ),
154
+ jsonb_build_object(
155
+ 'primary_key', array['template_id', 'depends_on_template_id']
156
+ )
157
+ );
158
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.23",
3
+ "version": "0.5.25",
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",
@@ -11,6 +11,18 @@ export class OperationCodegen {
11
11
  this.isCompositePK = this.primaryKey.length > 1 || this.primaryKey[0] !== 'id';
12
12
  }
13
13
 
14
+ /**
15
+ * Determine appropriate type cast for a column name
16
+ * Only casts to int if column is 'id' or ends with '_id'
17
+ * @private
18
+ */
19
+ _getTypeCast(columnName) {
20
+ if (columnName === 'id' || columnName.endsWith('_id')) {
21
+ return '::int';
22
+ }
23
+ return '';
24
+ }
25
+
14
26
  /**
15
27
  * Generate all operation functions
16
28
  * @returns {string} SQL for all operations
@@ -36,7 +48,7 @@ export class OperationCodegen {
36
48
  // For composite PKs, accept JSONB containing all PK fields
37
49
  // For simple PKs, accept INT for backwards compatibility
38
50
  if (this.isCompositePK) {
39
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
51
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
40
52
  const pkDescription = this.primaryKey.join(', ');
41
53
 
42
54
  return `-- GET operation for ${this.tableName} (composite primary key: ${pkDescription})
@@ -125,6 +137,21 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
125
137
  const m2mExpansion = this._generateM2MExpansion();
126
138
  const fieldDefaults = this._generateFieldDefaults();
127
139
 
140
+ // For composite PKs, generate a different function signature and logic
141
+ if (this.isCompositePK) {
142
+ return this._generateCompositePKSaveFunction({
143
+ graphRulesCall,
144
+ notificationSQL,
145
+ filterSensitiveFields,
146
+ m2mVariables,
147
+ m2mExtraction,
148
+ m2mSync,
149
+ m2mExpansion,
150
+ fieldDefaults
151
+ });
152
+ }
153
+
154
+ // Simple PK (id column) - original implementation for backwards compatibility
128
155
  return `-- SAVE operation for ${this.tableName}
129
156
  CREATE OR REPLACE FUNCTION save_${this.tableName}(
130
157
  p_user_id INT,
@@ -207,6 +234,141 @@ ${m2mExpansion}
207
234
  ${graphRulesCall}
208
235
  ${notificationSQL}
209
236
 
237
+ ${filterSensitiveFields}
238
+
239
+ RETURN v_output;
240
+ END;
241
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
242
+ }
243
+
244
+ /**
245
+ * Generate SAVE function for composite primary keys
246
+ * @private
247
+ */
248
+ _generateCompositePKSaveFunction(helpers) {
249
+ const {
250
+ graphRulesCall,
251
+ notificationSQL,
252
+ filterSensitiveFields,
253
+ m2mVariables,
254
+ m2mExtraction,
255
+ m2mSync,
256
+ m2mExpansion,
257
+ fieldDefaults
258
+ } = helpers;
259
+
260
+ const pkDescription = this.primaryKey.join(', ');
261
+
262
+ // Generate WHERE clause for composite PK lookup
263
+ // e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
264
+ const whereClause = this.primaryKey.map(col => {
265
+ return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
266
+ }).join(' AND ');
267
+
268
+ // For use inside format() string literal with %L placeholders
269
+ // e.g., "entity_type = %L AND entity_id = %L"
270
+ const whereClauseForFormat = this.primaryKey.map(col => `${col} = %L`).join(' AND ');
271
+
272
+ // Format arguments for the WHERE clause (to be passed to format())
273
+ // e.g., "(p_data->>'entity_type'), (p_data->>'entity_id')::int"
274
+ const whereClauseFormatArgs = this.primaryKey.map(col => {
275
+ return `(p_data->>'${col}')${this._getTypeCast(col)}`;
276
+ }).join(', ');
277
+
278
+ // Check if all PK fields are present in p_data
279
+ const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
280
+
281
+ // Build the list of PK field names for exclusion from SET clause
282
+ const pkFieldsExclusion = this.primaryKey.map(col => `jsonb_object_keys != '${col}'`).join(' AND ');
283
+
284
+ // For M2M sync and expansion, we need to reference the PK fields from v_result
285
+ const m2mSyncCompositePK = this._generateM2MSyncCompositePK();
286
+ const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
287
+ const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
288
+
289
+ return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
290
+ CREATE OR REPLACE FUNCTION save_${this.tableName}(
291
+ p_user_id INT,
292
+ p_data JSONB
293
+ ) RETURNS JSONB AS $$
294
+ DECLARE
295
+ v_result ${this.tableName}%ROWTYPE;
296
+ v_existing ${this.tableName}%ROWTYPE;
297
+ v_output JSONB;
298
+ v_before JSONB;
299
+ v_is_insert BOOLEAN := false;
300
+ v_notify_users INT[];
301
+ ${m2mVariables}
302
+ BEGIN
303
+ ${m2mExtraction}
304
+ -- Determine if this is insert or update (composite PK: ${pkDescription})
305
+ IF ${pkNullCheck} THEN
306
+ -- Missing one or more PK fields - this is an insert
307
+ v_is_insert := true;
308
+ ELSE
309
+ -- Try to fetch existing record by composite PK
310
+ SELECT * INTO v_existing
311
+ FROM ${this.tableName}
312
+ WHERE ${whereClause};
313
+
314
+ v_is_insert := NOT FOUND;
315
+ END IF;
316
+
317
+ -- Check permissions
318
+ IF v_is_insert THEN
319
+ IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
320
+ RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
321
+ END IF;
322
+ ELSE
323
+ IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
324
+ RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
325
+ END IF;
326
+ END IF;
327
+
328
+ -- Expand M2M for existing record (for UPDATE events "before" field)
329
+ IF NOT v_is_insert THEN
330
+ v_before := to_jsonb(v_existing);
331
+ ${m2mExpansionForBeforeCompositePK}
332
+ END IF;
333
+ ${fieldDefaults}
334
+ -- Perform UPSERT
335
+ IF v_is_insert THEN
336
+ -- Dynamic INSERT from JSONB
337
+ EXECUTE (
338
+ SELECT format(
339
+ 'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
340
+ string_agg(quote_ident(key), ', '),
341
+ string_agg(quote_nullable(value), ', ')
342
+ )
343
+ FROM jsonb_each_text(p_data) kv(key, value)
344
+ ) INTO v_result;
345
+ ELSE
346
+ -- Dynamic UPDATE from JSONB (only if there are non-PK fields to update)
347
+ IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE ${pkFieldsExclusion}) > 0 THEN
348
+ EXECUTE (
349
+ SELECT format(
350
+ 'UPDATE ${this.tableName} SET %s WHERE ${whereClauseForFormat} RETURNING *',
351
+ string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
352
+ ${whereClauseFormatArgs}
353
+ )
354
+ FROM jsonb_each_text(p_data) kv(key, value)
355
+ WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')}
356
+ ) INTO v_result;
357
+ ELSE
358
+ -- No fields to update (only M2M fields were provided), just fetch existing
359
+ v_result := v_existing;
360
+ END IF;
361
+ END IF;
362
+
363
+ ${m2mSyncCompositePK}
364
+
365
+ -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
366
+ v_output := to_jsonb(v_result);
367
+ ${m2mExpansionCompositePK}
368
+
369
+ ${graphRulesCall}
370
+ ${notificationSQL}
371
+
210
372
  ${filterSensitiveFields}
211
373
 
212
374
  RETURN v_output;
@@ -224,7 +386,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
224
386
 
225
387
  // For composite PKs, accept JSONB containing all PK fields
226
388
  if (this.isCompositePK) {
227
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
389
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
228
390
  const pkDescription = this.primaryKey.join(', ');
229
391
 
230
392
  const deleteSQL = this.entity.softDelete
@@ -740,6 +902,129 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
740
902
  return expansions.join('');
741
903
  }
742
904
 
905
+ /**
906
+ * Generate M2M sync for composite primary keys
907
+ * For tables with composite PKs, M2M relationships are rare but possible
908
+ * @private
909
+ */
910
+ _generateM2MSyncCompositePK() {
911
+ const manyToMany = this.entity.manyToMany || {};
912
+ if (Object.keys(manyToMany).length === 0) return '';
913
+
914
+ // For composite PKs, we need to build a composite key reference
915
+ // This is a rare case - most tables with composite PKs are junction tables themselves
916
+ const syncs = [];
917
+
918
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
919
+ const idField = config.id_field;
920
+ const junctionTable = config.junction_table;
921
+ const localKey = config.local_key;
922
+ const foreignKey = config.foreign_key;
923
+
924
+ // For composite PK tables with M2M, we'd need a different junction table structure
925
+ // This is an edge case - log a warning comment in the generated SQL
926
+ syncs.push(`
927
+ -- ============================================================================
928
+ -- M2M Sync: ${relationKey} (junction: ${junctionTable}) - COMPOSITE PK
929
+ -- Note: M2M on composite PK tables requires junction table to reference all PK columns
930
+ -- ============================================================================
931
+ IF v_${idField} IS NOT NULL THEN
932
+ -- Delete relationships not in new list (using first PK column as reference)
933
+ DELETE FROM ${junctionTable}
934
+ WHERE ${localKey} = v_result.${this.primaryKey[0]}
935
+ AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
936
+
937
+ -- Insert new relationships (idempotent)
938
+ IF array_length(v_${idField}, 1) > 0 THEN
939
+ INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
940
+ SELECT v_result.${this.primaryKey[0]}, unnest(v_${idField})
941
+ ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
942
+ END IF;
943
+ END IF;`);
944
+ }
945
+
946
+ return syncs.join('');
947
+ }
948
+
949
+ /**
950
+ * Generate M2M expansion for composite primary keys (for SAVE output)
951
+ * @private
952
+ */
953
+ _generateM2MExpansionCompositePK() {
954
+ const manyToMany = this.entity.manyToMany || {};
955
+ if (Object.keys(manyToMany).length === 0) return '';
956
+
957
+ const expansions = [];
958
+
959
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
960
+ const idField = config.id_field;
961
+ const junctionTable = config.junction_table;
962
+ const localKey = config.local_key;
963
+ const foreignKey = config.foreign_key;
964
+ const targetEntity = config.target_entity;
965
+ const expand = config.expand || false;
966
+
967
+ // Use first PK column for M2M lookups
968
+ expansions.push(`
969
+ -- Add M2M IDs: ${idField} (composite PK table)
970
+ v_output := v_output || jsonb_build_object('${idField}',
971
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
972
+ FROM ${junctionTable} WHERE ${localKey} = v_result.${this.primaryKey[0]})
973
+ );`);
974
+
975
+ if (expand) {
976
+ expansions.push(`
977
+ -- Expand M2M objects: ${relationKey} (expand=true, composite PK table)
978
+ v_output := v_output || jsonb_build_object('${relationKey}',
979
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
980
+ FROM ${junctionTable} jt
981
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
982
+ WHERE jt.${localKey} = v_result.${this.primaryKey[0]})
983
+ );`);
984
+ }
985
+ }
986
+
987
+ return expansions.join('');
988
+ }
989
+
990
+ /**
991
+ * Generate M2M expansion for "before" record with composite primary keys
992
+ * @private
993
+ */
994
+ _generateM2MExpansionForBeforeCompositePK() {
995
+ const manyToMany = this.entity.manyToMany || {};
996
+ if (Object.keys(manyToMany).length === 0) return '';
997
+
998
+ const expansions = [];
999
+
1000
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
1001
+ const idField = config.id_field;
1002
+ const junctionTable = config.junction_table;
1003
+ const localKey = config.local_key;
1004
+ const foreignKey = config.foreign_key;
1005
+ const targetEntity = config.target_entity;
1006
+ const expand = config.expand || false;
1007
+
1008
+ expansions.push(`
1009
+ v_before := v_before || jsonb_build_object('${idField}',
1010
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
1011
+ FROM ${junctionTable} WHERE ${localKey} = v_existing.${this.primaryKey[0]})
1012
+ );`);
1013
+
1014
+ if (expand) {
1015
+ expansions.push(`
1016
+ v_before := v_before || jsonb_build_object('${relationKey}',
1017
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
1018
+ FROM ${junctionTable} jt
1019
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
1020
+ WHERE jt.${localKey} = v_existing.${this.primaryKey[0]})
1021
+ );`);
1022
+ }
1023
+ }
1024
+
1025
+ return expansions.join('');
1026
+ }
1027
+
743
1028
  /**
744
1029
  * Generate FK expansions for GET
745
1030
  * @private
@@ -10,6 +10,7 @@ const DEFAULT_USER_ID = 1;
10
10
 
11
11
  /**
12
12
  * Discover available entities from dzql.entities table or compiled functions
13
+ * @returns {Promise<Object>} Map of entity name to {label, searchable, description}
13
14
  */
14
15
  async function discoverEntities() {
15
16
  // First try dzql.entities table (runtime mode)
@@ -58,6 +59,9 @@ async function discoverEntities() {
58
59
  /**
59
60
  * DZQL operations namespace - provides CLI-style access to DZQL operations
60
61
  *
62
+ * Each method outputs JSON to console and calls sql.end() before returning,
63
+ * making instances single-use. On error, methods call process.exit(1).
64
+ *
61
65
  * Usage in tasks.js:
62
66
  * ```js
63
67
  * import { DzqlNamespace } from 'dzql/namespace';
@@ -70,11 +74,17 @@ async function discoverEntities() {
70
74
  * ```
71
75
  */
72
76
  export class DzqlNamespace {
77
+ /**
78
+ * @param {number} [userId=1] - User ID for permission checks
79
+ */
73
80
  constructor(userId = DEFAULT_USER_ID) {
74
81
  this.userId = userId;
75
82
  }
76
83
 
77
- /** List all available entities */
84
+ /**
85
+ * List all available entities
86
+ * @returns {Promise<void>} Outputs JSON to console
87
+ */
78
88
  async entities(c) {
79
89
  try {
80
90
  const entities = await discoverEntities();
@@ -89,7 +99,12 @@ export class DzqlNamespace {
89
99
  }
90
100
  }
91
101
 
92
- /** Search an entity */
102
+ /**
103
+ * Search an entity
104
+ * @example invj dzql:search venues '{"query": "test"}'
105
+ * @param {string} entity - Entity/table name to search
106
+ * @param {string} [argsJson] - JSON string with search args (query, limit, offset, filters)
107
+ */
93
108
  async search(c, entity, argsJson = "{}") {
94
109
  if (!entity) {
95
110
  console.error("Error: entity name required");
@@ -123,7 +138,12 @@ export class DzqlNamespace {
123
138
  }
124
139
  }
125
140
 
126
- /** Get entity by ID */
141
+ /**
142
+ * Get entity by ID
143
+ * @example invj dzql:get venues '{"id": 1}'
144
+ * @param {string} entity - Entity/table name
145
+ * @param {string} [argsJson] - JSON string with {id: number}
146
+ */
127
147
  async get(c, entity, argsJson = "{}") {
128
148
  if (!entity) {
129
149
  console.error("Error: entity name required");
@@ -155,7 +175,12 @@ export class DzqlNamespace {
155
175
  }
156
176
  }
157
177
 
158
- /** Save (create or update) entity */
178
+ /**
179
+ * Save (create or update) entity
180
+ * @example invj dzql:save venues '{"name": "New Venue", "org_id": 1}'
181
+ * @param {string} entity - Entity/table name
182
+ * @param {string} [argsJson] - JSON string with entity data (include id to update, omit to create)
183
+ */
159
184
  async save(c, entity, argsJson = "{}") {
160
185
  if (!entity) {
161
186
  console.error("Error: entity name required");
@@ -189,7 +214,12 @@ export class DzqlNamespace {
189
214
  }
190
215
  }
191
216
 
192
- /** Delete entity by ID */
217
+ /**
218
+ * Delete entity by ID
219
+ * @example invj dzql:delete venues '{"id": 1}'
220
+ * @param {string} entity - Entity/table name
221
+ * @param {string} [argsJson] - JSON string with {id: number}
222
+ */
193
223
  async delete(c, entity, argsJson = "{}") {
194
224
  if (!entity) {
195
225
  console.error("Error: entity name required");
@@ -221,7 +251,12 @@ export class DzqlNamespace {
221
251
  }
222
252
  }
223
253
 
224
- /** Lookup entity (for dropdowns/autocomplete) */
254
+ /**
255
+ * Lookup entity (for dropdowns/autocomplete)
256
+ * @example invj dzql:lookup organisations '{"query": "acme"}'
257
+ * @param {string} entity - Entity/table name
258
+ * @param {string} [argsJson] - JSON string with {query: string, limit?: number}
259
+ */
225
260
  async lookup(c, entity, argsJson = "{}") {
226
261
  if (!entity) {
227
262
  console.error("Error: entity name required");