dzql 0.6.34 → 0.6.36

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
@@ -109,6 +109,7 @@ generated/
109
109
 
110
110
  - [Domain Modeling Guide](./for_ai.md) - Entity and permission patterns
111
111
  - [Project Setup](./project-setup.md) - Manual setup and configuration
112
+ - [Architecture Roadmap](./futures.md) - Performance and scaling plan
112
113
  - [Feature Requests](./feature-requests/) - Roadmap and proposals
113
114
 
114
115
  ## Package Exports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.34",
3
+ "version": "0.6.36",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,7 @@ export { generateSaveFunction } from "./save.js";
18
18
  export { generateDeleteFunction } from "./delete.js";
19
19
  export { generateGetFunction, generateHistoryFunction } from "./get.js";
20
20
  export { generateSearchFunction } from "./search.js";
21
+ export { generateLookupFunction } from "./lookup.js";
21
22
  export { buildVisibleJsonb, getCastForType, stripFKReferences } from "./utils.js";
22
23
  export type { ColumnInfo, EntityIR, ManyToManyIR, IncludeIR } from "./types.js";
23
24
 
@@ -26,6 +27,7 @@ import { generateSaveFunction } from "./save.js";
26
27
  import { generateDeleteFunction } from "./delete.js";
27
28
  import { generateGetFunction, generateHistoryFunction } from "./get.js";
28
29
  import { generateSearchFunction } from "./search.js";
30
+ import { generateLookupFunction } from "./lookup.js";
29
31
 
30
32
  // === AGGREGATE GENERATOR ===
31
33
  export function generateEntitySQL(name: string, entityIR: EntityIR): string {
@@ -37,7 +39,8 @@ export function generateEntitySQL(name: string, entityIR: EntityIR): string {
37
39
  generateSaveFunction(name, entityIR),
38
40
  generateDeleteFunction(name, entityIR),
39
41
  generateGetFunction(name, entityIR),
40
- generateSearchFunction(name, entityIR)
42
+ generateSearchFunction(name, entityIR),
43
+ generateLookupFunction(name, entityIR)
41
44
  ];
42
45
 
43
46
  // Add history function for temporal entities with refField
@@ -0,0 +1,62 @@
1
+ import { compilePermission } from "../../compiler/permissions.js";
2
+ import type { EntityIR } from "./types.js";
3
+
4
+ /**
5
+ * Generate Lookup Function
6
+ * Returns a set of { label: text, value: any } for autocomplete.
7
+ * Uses the entity's 'label' configuration for the display text.
8
+ */
9
+ export function generateLookupFunction(name: string, entityIR: EntityIR): string {
10
+ const pk = entityIR.primaryKey[0] || 'id';
11
+ const labelField = entityIR.labelField || pk;
12
+ const searchable = entityIR.searchable || [labelField];
13
+ const softDelete = entityIR.softDelete || false;
14
+ const temporal = entityIR.temporal;
15
+
16
+ const viewPermRaw = entityIR.permissions?.view?.length > 0
17
+ ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
18
+ : 'TRUE';
19
+ const viewPerm = viewPermRaw.replace(/p_user_id/g, '$1');
20
+
21
+ const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
22
+ const temporalFilter = temporal ? ` AND ${temporal.validTo} IS NULL` : '';
23
+
24
+ // Build ILIKE conditions for searchable fields
25
+ const searchConditions = searchable
26
+ .map((field: string) => `t.${field}::TEXT ILIKE $2`)
27
+ .join(' OR ');
28
+
29
+ return `
30
+ CREATE OR REPLACE FUNCTION dzql_v2.lookup_${name}(p_user_id int, p_query jsonb)
31
+ RETURNS SETOF jsonb
32
+ LANGUAGE plpgsql
33
+ SECURITY DEFINER
34
+ SET search_path = dzql_v2, public
35
+ AS $$
36
+ DECLARE
37
+ v_q text;
38
+ v_limit int;
39
+ BEGIN
40
+ v_q := COALESCE(p_query->>'q', '');
41
+ v_limit := COALESCE((p_query->>'limit')::int, 20);
42
+
43
+ IF v_q = '' THEN
44
+ RETURN QUERY
45
+ SELECT jsonb_build_object('label', t.${labelField}, 'value', t.${pk})
46
+ FROM ${name} t
47
+ WHERE (${viewPerm})${softDeleteFilter}${temporalFilter}
48
+ ORDER BY t.${labelField} ASC
49
+ LIMIT v_limit;
50
+ ELSE
51
+ RETURN QUERY
52
+ SELECT jsonb_build_object('label', t.${labelField}, 'value', t.${pk})
53
+ FROM ${name} t
54
+ WHERE (${viewPerm})${softDeleteFilter}${temporalFilter}
55
+ AND (${searchConditions.replace(/\$2/g, "LOWER('%' || v_q || '%')")})
56
+ ORDER BY t.${labelField} ASC
57
+ LIMIT v_limit;
58
+ END IF;
59
+ END;
60
+ $$;
61
+ `;
62
+ }
@@ -66,13 +66,12 @@ export function generateSearchFunction(name: string, entityIR: EntityIR): string
66
66
 
67
67
  return `
68
68
  CREATE OR REPLACE FUNCTION dzql_v2.search_${name}(p_user_id int, p_query jsonb)
69
- RETURNS jsonb
69
+ RETURNS SETOF jsonb
70
70
  LANGUAGE plpgsql
71
71
  SECURITY DEFINER
72
72
  SET search_path = dzql_v2, public
73
73
  AS $$
74
74
  DECLARE
75
- v_results jsonb;
76
75
  v_filters jsonb;
77
76
  v_sort_field text;
78
77
  v_sort_order text;
@@ -175,9 +174,9 @@ BEGIN
175
174
  END IF;
176
175
  END LOOP;
177
176
 
178
- -- Execute dynamic query
179
- EXECUTE format('
180
- SELECT COALESCE(jsonb_agg(${selectExpr}), ''[]''::jsonb)
177
+ -- Execute dynamic query and return set of jsonb
178
+ RETURN QUERY EXECUTE format('
179
+ SELECT ${selectExpr}
181
180
  FROM (
182
181
  SELECT * FROM ${name}
183
182
  WHERE (${viewPerm})${softDeleteFilter}${temporalFilter} %s
@@ -187,10 +186,7 @@ BEGIN
187
186
  ', v_where_clause, v_sort_field, v_sort_order,
188
187
  COALESCE((p_query->>'limit')::int, 10),
189
188
  COALESCE((p_query->>'offset')::int, 0))
190
- INTO v_results
191
189
  USING p_user_id;
192
-
193
- RETURN v_results;
194
190
  END;
195
191
  $$;
196
192
  `;
@@ -312,18 +312,19 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
312
312
  : '';
313
313
 
314
314
  if (isList) {
315
- // List subscribable - return array of records with nested includes
315
+ // List subscribable - return set of documents (STREAMING compatible)
316
+ // The Runtime will aggregate these or stream them.
317
+ // Schema metadata should be attached by the Runtime from the manifest.
316
318
  return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
317
319
  p_params JSONB,
318
320
  p_user_id INT
319
- ) RETURNS JSONB
321
+ ) RETURNS SETOF JSONB
320
322
  LANGUAGE plpgsql
321
323
  SECURITY DEFINER
322
324
  SET search_path = dzql_v2, public
323
325
  AS $$
324
326
  DECLARE
325
327
  ${paramDecls}
326
- v_data JSONB;
327
328
  BEGIN
328
329
  -- Extract parameters
329
330
  ${paramExtracts}
@@ -333,23 +334,15 @@ ${paramExtracts}
333
334
  RAISE EXCEPTION 'permission_denied';
334
335
  END IF;
335
336
 
336
- -- Build list of documents with nested relations
337
- SELECT COALESCE(jsonb_agg(
338
- to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
339
- ), '[]'::jsonb)
340
- INTO v_data
337
+ -- Return stream of documents
338
+ RETURN QUERY
339
+ SELECT to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
341
340
  FROM ${sub.root.entity} root
342
341
  ${whereClause};
343
-
344
- -- Return data with embedded schema for atomic updates
345
- RETURN jsonb_build_object(
346
- 'data', jsonb_build_object('${sub.root.entity}', v_data),
347
- 'schema', '${schemaJson}'::jsonb
348
- );
349
342
  END;
350
343
  $$;`;
351
344
  } else {
352
- // Single record subscribable
345
+ // Single record subscribable - return single JSONB
353
346
  return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
354
347
  p_params JSONB,
355
348
  p_user_id INT
@@ -378,11 +371,8 @@ ${paramExtracts}
378
371
  FROM ${sub.root.entity} root
379
372
  ${whereClause};
380
373
 
381
- -- Return data with embedded schema for atomic updates
382
- RETURN jsonb_build_object(
383
- 'data', v_data,
384
- 'schema', '${schemaJson}'::jsonb
385
- );
374
+ -- Return data
375
+ RETURN v_data;
386
376
  END;
387
377
  $$;`;
388
378
  }
@@ -185,6 +185,7 @@ export function generateIR(domain: DomainConfig): DomainIR {
185
185
  primaryKey: pk,
186
186
  columns,
187
187
  labelField: config.label || 'id',
188
+ searchable: config.searchable,
188
189
  softDelete: config.softDelete || false,
189
190
  managed: config.managed !== false, // Default to true, only false if explicitly set
190
191
  hidden: config.hidden || [],
@@ -85,7 +85,56 @@ export async function handleRequest(
85
85
 
86
86
  const sql = `SELECT ${qualifiedName}(${sqlArgs.join(', ')}) as result`;
87
87
  const rows = await db.query(sql, dbParams);
88
- return rows[0].result;
88
+
89
+ // 5. Post-Processing & Schema Attachment (for Subscribables)
90
+ const subName = method.startsWith('get_') ? method.slice(4) : null;
91
+ const subscribable = subName ? manifest.subscribables[subName] : null;
92
+
93
+ if (subscribable) {
94
+ // ... (existing code) ...
95
+ // It's a subscribable getter!
96
+ const pathMapping: Record<string, string> = {};
97
+ pathMapping[subscribable.root.entity] = '.';
98
+
99
+ const buildPaths = (incl: any, parent: string) => {
100
+ for (const [relName, relConfig] of Object.entries(incl || {})) {
101
+ const currentPath = parent ? `${parent}.${relName}` : relName;
102
+ pathMapping[(relConfig as any).entity] = currentPath;
103
+ if ((relConfig as any).includes) buildPaths((relConfig as any).includes, currentPath);
104
+ }
105
+ };
106
+ buildPaths(subscribable.includes, '');
107
+
108
+ const schema = {
109
+ root: subscribable.root.entity,
110
+ paths: pathMapping,
111
+ scopeTables: subscribable.scopeTables
112
+ };
113
+
114
+ let data: any;
115
+ if (!subscribable.root.key) {
116
+ // List Subscribable: Aggregate rows in Bun
117
+ const items = rows.map((r: any) => r.result).filter((i: any) => i !== null);
118
+ data = { [subscribable.root.entity]: items };
119
+ } else {
120
+ // Single Item Subscribable
121
+ data = rows[0]?.result || null;
122
+ }
123
+
124
+ return { data, schema };
125
+ }
126
+
127
+ // 6. Handle Search/Lookup (List results)
128
+ if (method.startsWith('search_') || method.startsWith('lookup_')) {
129
+ console.log(`[Runtime] Handling search/lookup: ${method}, rows:`, rows.length);
130
+ const results = rows.map((r: any) => r.result).filter((i: any) => i !== null);
131
+ console.log(`[Runtime] Mapped results type:`, Array.isArray(results) ? 'Array' : typeof results);
132
+ return results;
133
+ }
134
+
135
+ // Standard CRUD (get/save/delete) -> return single row result
136
+ return rows[0]?.result;
137
+
89
138
  } catch (err: any) {
90
139
  // 5. Error Sanitization
91
140
  console.error(`[Runtime] DB Error executing ${method}:`, err);
package/src/shared/ir.ts CHANGED
@@ -136,6 +136,7 @@ export interface EntityIR {
136
136
  primaryKey: string[];
137
137
  columns: Array<{ name: string; type: string; isArray: boolean }>;
138
138
  labelField?: string;
139
+ searchable?: string[];
139
140
  softDelete?: boolean;
140
141
  managed?: boolean; // If false, skip CRUD function generation (for junction tables)
141
142
  hidden?: string[]; // Fields to exclude from query results (e.g., password_hash)