dzql 0.6.15 → 0.6.17

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.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,8 +15,27 @@ export function generateClientSDK(manifest: Manifest): string {
15
15
  const subscriptionFunctions = Object.entries(manifest.functions)
16
16
  .filter(([_, def]) => def.isSubscription);
17
17
 
18
- // Generate entity types
19
- const typeDefs = generateTypeDefinitions(manifest.entities, manifest.subscribables);
18
+ // Build a map of custom functions with typed params/returns
19
+ const typedCustomFunctions = new Map<string, { hasParams: boolean; hasReturns: boolean; returnsScalar: boolean }>();
20
+ if (manifest.customFunctions) {
21
+ for (const fn of manifest.customFunctions) {
22
+ const hasParams = fn.params && Object.keys(fn.params).length > 0;
23
+ const hasReturns = fn.returns !== undefined;
24
+ const returnsScalar = typeof fn.returns === 'string';
25
+ typedCustomFunctions.set(fn.name, { hasParams: !!hasParams, hasReturns, returnsScalar });
26
+ }
27
+ }
28
+
29
+ // Generate entity types (now with auth, subscribable results, and custom function types)
30
+ const typeDefs = generateTypeDefinitions(
31
+ manifest.entities,
32
+ manifest.subscribables,
33
+ manifest.auth,
34
+ manifest.customFunctions
35
+ );
36
+
37
+ // Check if we have auth types
38
+ const hasAuth = manifest.auth !== undefined;
20
39
 
21
40
  // Generate API interface with proper types
22
41
  const apiMethods = Object.entries(manifest.functions).map(([funcName, def]) => {
@@ -29,18 +48,34 @@ export function generateClientSDK(manifest: Manifest): string {
29
48
  // Check if this entity exists in manifest
30
49
  const entityExists = manifest.entities[entity];
31
50
 
51
+ // Check if this is a subscribable
52
+ const subscribableExists = manifest.subscribables?.[entity];
53
+
32
54
  let paramType = 'Record<string, unknown>';
33
55
  let returnType = 'unknown';
34
56
 
57
+ // Handle auth functions
58
+ if (funcName === 'login_user' && hasAuth) {
59
+ return ` ${funcName}: (params: LoginParams) => Promise<LoginResult>;`;
60
+ }
61
+ if (funcName === 'register_user' && hasAuth) {
62
+ return ` ${funcName}: (params: RegisterParams) => Promise<RegisterResult>;`;
63
+ }
64
+
65
+ // Handle subscriptions
35
66
  if (isSubscription) {
36
- // subscribe_venue_detail -> VenueDetailParams
67
+ // subscribe_venue_detail -> VenueDetailParams, VenueDetailResult
37
68
  paramType = `${pascalEntity}Params`;
38
- return ` ${funcName}: (params: ${paramType}, callback: (data: unknown) => void) => Promise<{ data: unknown; subscription_id: string; schema: unknown; unsubscribe: () => Promise<void> }>;`;
39
- } else if (op === 'get' && entityExists) {
69
+ const resultType = subscribableExists ? `${pascalEntity}Result` : 'unknown';
70
+ return ` ${funcName}: (params: ${paramType}, callback: (data: ${resultType}) => void) => Promise<{ data: ${resultType}; subscription_id: string; schema: unknown; unsubscribe: () => Promise<void> }>;`;
71
+ }
72
+
73
+ // Handle entity CRUD operations
74
+ if (op === 'get' && entityExists) {
40
75
  // get_venue_detail (subscribable getter) vs get_venues (entity getter)
41
- if (manifest.subscribables?.[entity]) {
76
+ if (subscribableExists) {
42
77
  paramType = `${pascalEntity}Params`;
43
- returnType = 'unknown';
78
+ returnType = `${pascalEntity}Result`;
44
79
  } else {
45
80
  paramType = `${pascalEntity}PK`;
46
81
  returnType = `${pascalEntity} | null`;
@@ -58,7 +93,24 @@ export function generateClientSDK(manifest: Manifest): string {
58
93
  paramType = `Lookup${pascalEntity}Params`;
59
94
  returnType = `${pascalEntity}[]`;
60
95
  }
61
- // Custom functions and auth functions stay as Record<string, unknown>
96
+
97
+ // Handle custom functions with typed params/returns
98
+ const customFnInfo = typedCustomFunctions.get(funcName);
99
+ if (customFnInfo) {
100
+ const pascalFuncName = toPascalCase(funcName);
101
+ if (customFnInfo.hasParams) {
102
+ paramType = `${pascalFuncName}Params`;
103
+ }
104
+ if (customFnInfo.hasReturns) {
105
+ if (customFnInfo.returnsScalar) {
106
+ // Find the scalar type from the manifest
107
+ const fn = manifest.customFunctions?.find(f => f.name === funcName);
108
+ returnType = fn?.returns as string || 'unknown';
109
+ } else {
110
+ returnType = `${pascalFuncName}Result`;
111
+ }
112
+ }
113
+ }
62
114
 
63
115
  return ` ${funcName}: (params: ${paramType}) => Promise<${returnType}>;`;
64
116
  }).join('\n');
@@ -1,10 +1,12 @@
1
- import { DomainIR, EntityIR, SubscribableIR } from "../../shared/ir.js";
1
+ import { DomainIR, EntityIR, SubscribableIR, AuthIR, CustomFunctionIR } from "../../shared/ir.js";
2
2
 
3
3
  export interface Manifest {
4
4
  version: string;
5
5
  functions: Record<string, FunctionDef>;
6
6
  entities: Record<string, EntityIR>;
7
7
  subscribables: Record<string, SubscribableIR>;
8
+ auth?: AuthIR;
9
+ customFunctions?: CustomFunctionIR[];
8
10
  }
9
11
 
10
12
  export interface FunctionDef {
@@ -90,6 +92,8 @@ export function generateManifest(ir: DomainIR): Manifest {
90
92
  version: "2.0.0",
91
93
  functions,
92
94
  entities: ir.entities, // Pass through for client generator
93
- subscribables: ir.subscribables
95
+ subscribables: ir.subscribables,
96
+ auth: ir.auth,
97
+ customFunctions: ir.customFunctions
94
98
  };
95
99
  }
@@ -1,6 +1,6 @@
1
1
  import { compilePermission } from "../compiler/permissions.js";
2
2
  import { compileGraphRules } from "../compiler/graph_rules.js";
3
- import type { EntityIR, ManyToManyIR } from "../../shared/ir.js";
3
+ import type { EntityIR, ManyToManyIR, IncludeIR } from "../../shared/ir.js";
4
4
 
5
5
  /** Column info from EntityIR */
6
6
  interface ColumnInfo {
@@ -313,6 +313,32 @@ export function generateSaveFunction(name: string, entityIR: EntityIR): string {
313
313
  return sql;
314
314
  }).join('\n');
315
315
 
316
+ // FK expansion (add related objects to output for real-time events)
317
+ // Only expand direct FKs (where key_id column exists), not reverse FKs (child arrays)
318
+ const includes: Record<string, IncludeIR> = entityIR.includes || {};
319
+ const includeKeys = Object.keys(includes);
320
+ const fkExpansion = includeKeys.map(key => {
321
+ const config: IncludeIR = includes[key];
322
+ const targetEntity = config.entity;
323
+ const fkField = `${key}_id`; // Convention: author -> author_id
324
+
325
+ // Only expand if this is a direct FK (key_id column exists)
326
+ const hasFkColumn = entityIR.columns.some((c: ColumnInfo) => c.name === fkField);
327
+
328
+ if (hasFkColumn) {
329
+ // Direct FK: single object expansion (e.g., author_id -> author object)
330
+ return `
331
+ -- FK: Add ${key} to output (from ${fkField})
332
+ IF (v_result->>'${fkField}') IS NOT NULL THEN
333
+ v_result := v_result || jsonb_build_object('${key}',
334
+ (SELECT to_jsonb(t.*) FROM ${targetEntity} t WHERE t.id = (v_result->>'${fkField}')::int));
335
+ END IF;`;
336
+ }
337
+ // Skip reverse FKs - they would require querying child tables which may not exist
338
+ // and are not needed for the primary use case of expanding the saved record
339
+ return '';
340
+ }).filter(s => s).join('\n');
341
+
316
342
  return `
317
343
  CREATE OR REPLACE FUNCTION dzql_v2.save_${name}(p_user_id int, p_data jsonb)
318
344
  RETURNS jsonb
@@ -365,6 +391,7 @@ ${m2mExtraction}
365
391
  END IF;
366
392
  ${m2mSync}
367
393
  ${m2mExpansion}
394
+ ${fkExpansion}
368
395
 
369
396
  -- Resolve notification recipients
370
397
  v_notify_users := dzql_v2.${name}_notify_users(p_user_id, v_result);
@@ -1,4 +1,4 @@
1
- import { EntityIR, SubscribableIR } from "../../shared/ir.js";
1
+ import { EntityIR, SubscribableIR, AuthIR, CustomFunctionIR } from "../../shared/ir.js";
2
2
 
3
3
  const TYPE_MAP: Record<string, string> = {
4
4
  'text': 'string',
@@ -19,6 +19,7 @@ const PARAM_TYPE_MAP: Record<string, string> = {
19
19
  'integer': 'number',
20
20
  'text': 'string',
21
21
  'string': 'string',
22
+ 'number': 'number',
22
23
  'boolean': 'boolean',
23
24
  'date': 'string',
24
25
  'timestamptz': 'string'
@@ -26,7 +27,9 @@ const PARAM_TYPE_MAP: Record<string, string> = {
26
27
 
27
28
  export function generateTypeDefinitions(
28
29
  entities: Record<string, EntityIR>,
29
- subscribables?: Record<string, SubscribableIR>
30
+ subscribables?: Record<string, SubscribableIR>,
31
+ auth?: AuthIR,
32
+ customFunctions?: CustomFunctionIR[]
30
33
  ): string {
31
34
  let output = "";
32
35
 
@@ -49,6 +52,7 @@ export type FilterValue<T> = T | FilterOperators<T>;
49
52
 
50
53
  `;
51
54
 
55
+ // --- Entity Types ---
52
56
  for (const [name, entity] of Object.entries(entities)) {
53
57
  const pascalName = toPascalCase(name);
54
58
 
@@ -106,17 +110,107 @@ export type FilterValue<T> = T | FilterOperators<T>;
106
110
  }\n\n`;
107
111
  }
108
112
 
109
- // --- Subscribable Params ---
113
+ // --- Subscribable Params and Result Types ---
110
114
  if (subscribables) {
111
115
  for (const [name, sub] of Object.entries(subscribables)) {
112
116
  const pascalName = toPascalCase(name);
113
117
 
118
+ // Params interface
114
119
  output += `export interface ${pascalName}Params {\n`;
115
120
  for (const [paramName, paramType] of Object.entries(sub.params)) {
116
121
  const tsType = PARAM_TYPE_MAP[paramType as string] || 'any';
117
122
  output += ` ${paramName}: ${tsType};\n`;
118
123
  }
119
124
  output += `}\n\n`;
125
+
126
+ // Result interface (extends root entity with includes)
127
+ const rootEntity = sub.root?.entity;
128
+ const rootEntityPascal = rootEntity ? toPascalCase(rootEntity) : null;
129
+
130
+ if (rootEntityPascal && entities[rootEntity]) {
131
+ output += `export interface ${pascalName}Result extends ${rootEntityPascal} {\n`;
132
+
133
+ // Add includes as optional fields
134
+ if (sub.includes) {
135
+ for (const [includeKey, includeIR] of Object.entries(sub.includes)) {
136
+ const includeEntity = includeIR.entity;
137
+ const includeEntityPascal = toPascalCase(includeEntity);
138
+
139
+ // Determine if it's an array (one-to-many) or single (many-to-one)
140
+ // For now, assume arrays unless the include key matches a FK pattern
141
+ const isArray = !includeKey.endsWith('_id') && includeKey !== rootEntity;
142
+ const includeType = isArray ? `${includeEntityPascal}[]` : includeEntityPascal;
143
+
144
+ output += ` ${includeKey}?: ${includeType};\n`;
145
+ }
146
+ }
147
+
148
+ output += `}\n\n`;
149
+ }
150
+ }
151
+ }
152
+
153
+ // --- Auth Types ---
154
+ if (auth) {
155
+ // AuthUser interface
156
+ output += `export interface AuthUser {\n`;
157
+ for (const [fieldName, fieldType] of Object.entries(auth.userFields)) {
158
+ const tsType = PARAM_TYPE_MAP[fieldType] || fieldType;
159
+ output += ` ${fieldName}: ${tsType};\n`;
160
+ }
161
+ output += `}\n\n`;
162
+
163
+ // LoginParams interface
164
+ output += `export interface LoginParams {\n`;
165
+ for (const [paramName, paramType] of Object.entries(auth.loginParams)) {
166
+ const tsType = PARAM_TYPE_MAP[paramType] || paramType;
167
+ output += ` ${paramName}: ${tsType};\n`;
168
+ }
169
+ output += `}\n\n`;
170
+
171
+ // LoginResult interface
172
+ output += `export interface LoginResult extends AuthUser {\n`;
173
+ output += ` token: string;\n`;
174
+ output += `}\n\n`;
175
+
176
+ // RegisterParams interface
177
+ output += `export interface RegisterParams {\n`;
178
+ for (const [paramName, paramType] of Object.entries(auth.registerParams)) {
179
+ const tsType = PARAM_TYPE_MAP[paramType] || paramType;
180
+ output += ` ${paramName}: ${tsType};\n`;
181
+ }
182
+ output += `}\n\n`;
183
+
184
+ // RegisterResult interface
185
+ output += `export interface RegisterResult extends AuthUser {\n`;
186
+ output += ` token: string;\n`;
187
+ output += `}\n\n`;
188
+ }
189
+
190
+ // --- Custom Function Types ---
191
+ if (customFunctions) {
192
+ for (const fn of customFunctions) {
193
+ const pascalName = toPascalCase(fn.name);
194
+
195
+ // Generate params interface if params are defined
196
+ if (fn.params && Object.keys(fn.params).length > 0) {
197
+ output += `export interface ${pascalName}Params {\n`;
198
+ for (const [paramName, paramType] of Object.entries(fn.params)) {
199
+ const tsType = PARAM_TYPE_MAP[paramType] || paramType;
200
+ output += ` ${paramName}: ${tsType};\n`;
201
+ }
202
+ output += `}\n\n`;
203
+ }
204
+
205
+ // Generate result interface if returns is an object
206
+ if (fn.returns && typeof fn.returns === 'object') {
207
+ output += `export interface ${pascalName}Result {\n`;
208
+ for (const [fieldName, fieldType] of Object.entries(fn.returns)) {
209
+ const tsType = PARAM_TYPE_MAP[fieldType] || fieldType;
210
+ output += ` ${fieldName}: ${tsType};\n`;
211
+ }
212
+ output += `}\n\n`;
213
+ }
120
214
  }
121
215
  }
122
216
 
@@ -12,7 +12,8 @@ import type {
12
12
  IncludeIR,
13
13
  CustomFunctionIR,
14
14
  ManyToManyIR,
15
- GraphRuleIR
15
+ GraphRuleIR,
16
+ AuthIR
16
17
  } from "../../shared/ir.js";
17
18
 
18
19
  /**
@@ -175,6 +176,9 @@ export function generateIR(domain: DomainConfig): DomainIR {
175
176
  };
176
177
  }
177
178
 
179
+ // Parse includes (FK expansions)
180
+ const includes = parseIncludes(config.includes as Record<string, string | IncludeConfig> | undefined);
181
+
178
182
  entities[name] = {
179
183
  name,
180
184
  table: name,
@@ -187,6 +191,7 @@ export function generateIR(domain: DomainConfig): DomainIR {
187
191
  fieldDefaults: config.fieldDefaults || {},
188
192
  permissions,
189
193
  relationships: {},
194
+ includes,
190
195
  manyToMany,
191
196
  graphRules: {
192
197
  onCreate: onCreateRules,
@@ -220,14 +225,68 @@ export function generateIR(domain: DomainConfig): DomainIR {
220
225
  customFunctions.push({
221
226
  name: fn.name,
222
227
  sql: fn.sql,
223
- args: fn.args || ['p_user_id', 'p_params']
228
+ args: fn.args || ['p_user_id', 'p_params'],
229
+ params: fn.params,
230
+ returns: fn.returns
224
231
  });
225
232
  }
226
233
  }
227
234
 
235
+ // --- AUTH CONFIG ---
236
+ // Generate default auth config based on users entity if not provided
237
+ let auth: AuthIR | undefined;
238
+
239
+ if (domain.auth) {
240
+ // Use explicit auth config
241
+ auth = {
242
+ userFields: domain.auth.userFields || {},
243
+ loginParams: domain.auth.loginParams || { email: 'string', password: 'string' },
244
+ registerParams: domain.auth.registerParams || { email: 'string', password: 'string' }
245
+ };
246
+ } else if (entities['users']) {
247
+ // Derive auth config from users entity
248
+ const usersEntity = entities['users'];
249
+ const hiddenFields = new Set(usersEntity.hidden || []);
250
+ hiddenFields.add('password_hash');
251
+ hiddenFields.add('password');
252
+
253
+ // Map user columns to TypeScript types (excluding hidden fields)
254
+ const userFields: Record<string, string> = {
255
+ user_id: 'number' // Always include user_id
256
+ };
257
+
258
+ for (const col of usersEntity.columns) {
259
+ if (!hiddenFields.has(col.name) && col.name !== 'id') {
260
+ userFields[col.name] = mapPgTypeToTsType(col.type);
261
+ }
262
+ }
263
+
264
+ auth = {
265
+ userFields,
266
+ loginParams: { email: 'string', password: 'string' },
267
+ registerParams: { email: 'string', password: 'string' }
268
+ };
269
+ }
270
+
228
271
  return {
229
272
  entities,
230
273
  subscribables,
231
- customFunctions
274
+ customFunctions,
275
+ auth
232
276
  };
233
277
  }
278
+
279
+ /**
280
+ * Maps PostgreSQL column type to TypeScript type string
281
+ */
282
+ function mapPgTypeToTsType(pgType: string): string {
283
+ const lower = pgType.toLowerCase();
284
+ if (lower.includes('int') || lower.includes('serial') || lower.includes('decimal') || lower.includes('numeric')) {
285
+ return 'number';
286
+ }
287
+ if (lower.includes('bool')) {
288
+ return 'boolean';
289
+ }
290
+ // Default to string for text, timestamps, etc.
291
+ return 'string';
292
+ }
package/src/shared/ir.ts CHANGED
@@ -100,6 +100,20 @@ export interface CustomFunctionConfig {
100
100
  name: string;
101
101
  sql: string;
102
102
  args?: string[];
103
+ /** Parameter types for TypeScript generation: { paramName: 'number' | 'string' | ... } */
104
+ params?: Record<string, string>;
105
+ /** Return type: either an object shape or a scalar type name */
106
+ returns?: Record<string, string> | string;
107
+ }
108
+
109
+ /** Auth configuration for TypeScript type generation */
110
+ export interface AuthConfig {
111
+ /** Fields returned by auth functions (user profile shape) */
112
+ userFields?: Record<string, string>;
113
+ /** Login function parameter types */
114
+ loginParams?: Record<string, string>;
115
+ /** Register function parameter types */
116
+ registerParams?: Record<string, string>;
103
117
  }
104
118
 
105
119
  /** Complete domain configuration as provided in domain file */
@@ -107,6 +121,7 @@ export interface DomainConfig {
107
121
  entities: Record<string, EntityConfig>;
108
122
  subscribables?: Record<string, SubscribableConfig>;
109
123
  customFunctions?: CustomFunctionConfig[];
124
+ auth?: AuthConfig;
110
125
  }
111
126
 
112
127
  // ============================================
@@ -130,6 +145,7 @@ export interface EntityIR {
130
145
  delete: string[];
131
146
  };
132
147
  relationships: Record<string, RelationshipIR>;
148
+ includes: Record<string, IncludeIR>; // FK expansions (e.g., author: users)
133
149
  manyToMany: Record<string, ManyToManyIR>;
134
150
  graphRules: {
135
151
  onCreate: GraphRuleIR[];
@@ -190,10 +206,25 @@ export interface CustomFunctionIR {
190
206
  name: string;
191
207
  sql: string;
192
208
  args?: string[]; // For manifest allowlist
209
+ /** Parameter types for TypeScript generation */
210
+ params?: Record<string, string>;
211
+ /** Return type: either an object shape or a scalar type name */
212
+ returns?: Record<string, string> | string;
213
+ }
214
+
215
+ /** Auth IR for TypeScript type generation */
216
+ export interface AuthIR {
217
+ /** Fields returned by auth functions (user profile shape) */
218
+ userFields: Record<string, string>;
219
+ /** Login function parameter types */
220
+ loginParams: Record<string, string>;
221
+ /** Register function parameter types */
222
+ registerParams: Record<string, string>;
193
223
  }
194
224
 
195
225
  export interface DomainIR {
196
226
  entities: Record<string, EntityIR>;
197
227
  subscribables: Record<string, SubscribableIR>;
198
228
  customFunctions: CustomFunctionIR[];
229
+ auth?: AuthIR;
199
230
  }