dzql 0.6.17 → 0.6.19

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.17",
3
+ "version": "0.6.19",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,9 +20,9 @@ export interface FunctionDef {
20
20
 
21
21
  export function generateManifest(ir: DomainIR): Manifest {
22
22
  const functions: Record<string, FunctionDef> = {
23
- // Built-in Auth Functions
24
- login_user: { schema: 'dzql_v2', name: 'login_user', args: ['p_params'], returnType: 'jsonb' },
25
- register_user: { schema: 'dzql_v2', name: 'register_user', args: ['p_params'], returnType: 'jsonb' }
23
+ // Built-in Auth Functions (individual params, not jsonb)
24
+ login_user: { schema: 'dzql_v2', name: 'login_user', args: ['p_email', 'p_password'], returnType: 'jsonb' },
25
+ register_user: { schema: 'dzql_v2', name: 'register_user', args: ['p_email', 'p_password', 'p_options'], returnType: 'jsonb' }
26
26
  };
27
27
 
28
28
  for (const [name, entity] of Object.entries(ir.entities)) {
@@ -71,11 +71,40 @@ BEGIN
71
71
  RETURN ARRAY[]::text[];
72
72
  END;
73
73
  $$;
74
+ `;
75
+ }
74
76
 
75
- -- === AUTH FUNCTIONS ===
77
+ /**
78
+ * Generate auth SQL functions.
79
+ * These depend on the 'users' table existing, so must be applied after schema.
80
+ */
81
+ export function generateAuthSQL() {
82
+ return `
83
+ -- === AUTH SYSTEM ===
84
+ -- These functions depend on a 'users' table being created by the domain schema.
85
+ -- The users table must have: id (serial PK), email (text unique), password_hash (text)
86
+ -- Additional columns can be added and will be returned by _profile() automatically.
87
+
88
+ -- Get user profile (private function)
89
+ -- Returns all user columns except sensitive fields (password_hash, password, secret, token)
90
+ -- This allows the users table to have any additional columns without modifying this function
91
+ CREATE OR REPLACE FUNCTION dzql_v2._profile(p_user_id int)
92
+ RETURNS jsonb
93
+ LANGUAGE sql
94
+ SECURITY DEFINER
95
+ SET search_path = dzql_v2, public
96
+ AS $$
97
+ SELECT jsonb_build_object('user_id', u.id) || (to_jsonb(u.*) - 'id' - 'password_hash' - 'password' - 'secret' - 'token')
98
+ FROM users u
99
+ WHERE id = p_user_id;
100
+ $$;
76
101
 
77
- -- Register User
78
- CREATE OR REPLACE FUNCTION dzql_v2.register_user(p_params jsonb)
102
+ -- Register new user
103
+ -- p_email: User's email address (must be unique)
104
+ -- p_password: User's password (will be hashed)
105
+ -- p_options: Optional JSON object with additional fields to set on the user record
106
+ -- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
107
+ CREATE OR REPLACE FUNCTION dzql_v2.register_user(p_email text, p_password text, p_options jsonb DEFAULT NULL)
79
108
  RETURNS jsonb
80
109
  LANGUAGE plpgsql
81
110
  SECURITY DEFINER
@@ -83,37 +112,46 @@ SET search_path = dzql_v2, public
83
112
  AS $$
84
113
  DECLARE
85
114
  v_user_id int;
86
- v_email text;
87
- v_password text;
88
- v_name text;
89
- v_options jsonb;
115
+ v_salt text;
116
+ v_hash text;
117
+ v_insert_data jsonb;
90
118
  BEGIN
91
- v_email := p_params->>'email';
92
- v_password := p_params->>'password';
93
- v_name := COALESCE(p_params->>'name', v_email);
94
- v_options := COALESCE(p_params->'options', '{}'::jsonb);
95
-
96
- IF v_email IS NULL OR v_password IS NULL THEN
119
+ IF p_email IS NULL OR p_password IS NULL THEN
97
120
  RAISE EXCEPTION 'validation_error: email and password required';
98
121
  END IF;
99
122
 
100
- INSERT INTO users (email, password_hash, name)
101
- VALUES (v_email, crypt(v_password, gen_salt('bf')), v_name)
102
- RETURNING id INTO v_user_id;
123
+ -- Generate salt and hash password
124
+ v_salt := gen_salt('bf', 10);
125
+ v_hash := crypt(p_password, v_salt);
103
126
 
104
- -- TODO: Handle v_options if needed (e.g. creating orgs)
127
+ -- Build insert data: options fields + email + password_hash (options cannot override core fields)
128
+ v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
129
+ IF p_options IS NOT NULL THEN
130
+ v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
131
+ END IF;
105
132
 
106
- -- Return minimal profile (Token generation happens in Runtime layer)
107
- RETURN jsonb_build_object(
108
- 'user_id', v_user_id,
109
- 'email', v_email,
110
- 'name', v_name
111
- );
133
+ -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
134
+ EXECUTE (
135
+ SELECT format(
136
+ 'INSERT INTO users (%s) VALUES (%s) RETURNING id',
137
+ string_agg(quote_ident(key), ', '),
138
+ string_agg(quote_nullable(value), ', ')
139
+ )
140
+ FROM jsonb_each_text(v_insert_data) kv(key, value)
141
+ ) INTO v_user_id;
142
+
143
+ -- Return profile (Token generation happens in Runtime layer)
144
+ RETURN dzql_v2._profile(v_user_id);
145
+ EXCEPTION
146
+ WHEN unique_violation THEN
147
+ RAISE EXCEPTION 'validation_error: Email already exists' USING ERRCODE = '23505';
112
148
  END;
113
149
  $$;
114
150
 
115
- -- Login User
116
- CREATE OR REPLACE FUNCTION dzql_v2.login_user(p_params jsonb)
151
+ -- Login user
152
+ -- p_email: User's email address
153
+ -- p_password: User's password
154
+ CREATE OR REPLACE FUNCTION dzql_v2.login_user(p_email text, p_password text)
117
155
  RETURNS jsonb
118
156
  LANGUAGE plpgsql
119
157
  SECURITY DEFINER
@@ -122,17 +160,20 @@ AS $$
122
160
  DECLARE
123
161
  v_user record;
124
162
  BEGIN
125
- SELECT * INTO v_user FROM users WHERE email = p_params->>'email';
163
+ SELECT id, email, password_hash
164
+ INTO v_user
165
+ FROM users
166
+ WHERE email = p_email;
126
167
 
127
- IF v_user IS NULL OR v_user.password_hash != crypt(p_params->>'password', v_user.password_hash) THEN
128
- RAISE EXCEPTION 'permission_denied: invalid credentials';
168
+ IF NOT FOUND THEN
169
+ RAISE EXCEPTION 'permission_denied: invalid credentials' USING ERRCODE = '28000';
129
170
  END IF;
130
171
 
131
- RETURN jsonb_build_object(
132
- 'user_id', v_user.id,
133
- 'email', v_user.email,
134
- 'name', v_user.name
135
- );
172
+ IF NOT (v_user.password_hash = crypt(p_password, v_user.password_hash)) THEN
173
+ RAISE EXCEPTION 'permission_denied: invalid credentials' USING ERRCODE = '28000';
174
+ END IF;
175
+
176
+ RETURN dzql_v2._profile(v_user.id);
136
177
  END;
137
178
  $$;
138
179
  `;
@@ -22,7 +22,8 @@ const PARAM_TYPE_MAP: Record<string, string> = {
22
22
  'number': 'number',
23
23
  'boolean': 'boolean',
24
24
  'date': 'string',
25
- 'timestamptz': 'string'
25
+ 'timestamptz': 'string',
26
+ 'object': 'Record<string, unknown>'
26
27
  };
27
28
 
28
29
  export function generateTypeDefinitions(
@@ -132,14 +133,18 @@ export type FilterValue<T> = T | FilterOperators<T>;
132
133
 
133
134
  // Add includes as optional fields
134
135
  if (sub.includes) {
136
+ const rootEntityIR = entities[rootEntity];
137
+ const rootColumns = new Set(rootEntityIR.columns.map(c => c.name));
138
+
135
139
  for (const [includeKey, includeIR] of Object.entries(sub.includes)) {
136
140
  const includeEntity = includeIR.entity;
137
141
  const includeEntityPascal = toPascalCase(includeEntity);
138
142
 
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
+ // Determine if it's many-to-one (singular) or one-to-many (array)
144
+ // If root entity has a column `{includeKey}_id`, it's many-to-one
145
+ const fkColumn = `${includeKey}_id`;
146
+ const isManyToOne = rootColumns.has(fkColumn);
147
+ const includeType = isManyToOne ? includeEntityPascal : `${includeEntityPascal}[]`;
143
148
 
144
149
  output += ` ${includeKey}?: ${includeType};\n`;
145
150
  }
@@ -177,7 +182,9 @@ export type FilterValue<T> = T | FilterOperators<T>;
177
182
  output += `export interface RegisterParams {\n`;
178
183
  for (const [paramName, paramType] of Object.entries(auth.registerParams)) {
179
184
  const tsType = PARAM_TYPE_MAP[paramType] || paramType;
180
- output += ` ${paramName}: ${tsType};\n`;
185
+ // options is optional (p_options jsonb DEFAULT NULL)
186
+ const optional = paramName === 'options' ? '?' : '';
187
+ output += ` ${paramName}${optional}: ${tsType};\n`;
181
188
  }
182
189
  output += `}\n\n`;
183
190
 
@@ -241,7 +241,7 @@ export function generateIR(domain: DomainConfig): DomainIR {
241
241
  auth = {
242
242
  userFields: domain.auth.userFields || {},
243
243
  loginParams: domain.auth.loginParams || { email: 'string', password: 'string' },
244
- registerParams: domain.auth.registerParams || { email: 'string', password: 'string' }
244
+ registerParams: domain.auth.registerParams || { email: 'string', password: 'string', options: 'object' }
245
245
  };
246
246
  } else if (entities['users']) {
247
247
  // Derive auth config from users entity
@@ -264,7 +264,7 @@ export function generateIR(domain: DomainConfig): DomainIR {
264
264
  auth = {
265
265
  userFields,
266
266
  loginParams: { email: 'string', password: 'string' },
267
- registerParams: { email: 'string', password: 'string' }
267
+ registerParams: { email: 'string', password: 'string', options: 'object' }
268
268
  };
269
269
  }
270
270
 
package/src/cli/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { loadDomain } from "./compiler/loader.js";
3
3
  import { analyzeDomain } from "./compiler/analyzer.js";
4
4
  import { generateIR } from "./compiler/ir.js";
5
- import { generateCoreSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
5
+ import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
6
6
  import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
7
7
  import { generateManifest } from "./codegen/manifest.js";
8
8
  import { generateSubscribableStore } from "./codegen/subscribable_store.js";
@@ -107,10 +107,14 @@ async function main() {
107
107
  writeFileSync(resolve(dbDir, `000_core.sql`), coreSQL);
108
108
  const timestamp = new Date().toISOString().replace(/[:.-]/g, '');
109
109
 
110
- // Combine entity SQL with custom functions
111
- const schemaContent = customFunctionSQL.length > 0
112
- ? entitySQL.join('\n') + '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n')
113
- : entitySQL.join('\n');
110
+ // Combine entity SQL with auth functions and custom functions
111
+ // Auth functions must come after schema (they depend on users table)
112
+ const authSQL = generateAuthSQL();
113
+ let schemaContent = entitySQL.join('\n');
114
+ schemaContent += '\n\n-- Auth Functions\n' + authSQL;
115
+ if (customFunctionSQL.length > 0) {
116
+ schemaContent += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
117
+ }
114
118
  writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaContent);
115
119
 
116
120
  if (customFunctionSQL.length > 0) {
@@ -53,8 +53,8 @@ export async function handleRequest(
53
53
  try {
54
54
  // Construct params array based on manifest definition
55
55
  // args: ["p_user_id", "p_data"] -> [$1, $2]
56
- // args: ["p_params"] -> [$2] (since $2 is the data param)
57
- // We map: p_user_id -> userId ($1), p_data/p_pk/p_params -> params ($2)
56
+ // args: ["p_email", "p_password"] -> extract from params object
57
+ // We map: p_user_id -> userId, p_data/p_pk/p_params -> params (jsonb), individual args -> params[key]
58
58
 
59
59
  const dbParams = [];
60
60
  const sqlArgs = [];
@@ -66,8 +66,16 @@ export async function handleRequest(
66
66
  dbParams.push(userId);
67
67
  sqlArgs.push(`$${dbParams.length}`);
68
68
  } else if (arg === 'p_data' || arg === 'p_pk' || arg === 'p_query' || arg === 'p_params') {
69
+ // Pass entire params object as jsonb
69
70
  dbParams.push(params);
70
71
  sqlArgs.push(`$${dbParams.length}`);
72
+ } else if (arg.startsWith('p_')) {
73
+ // Individual param: extract from params object by field name
74
+ // e.g., p_email -> params.email, p_password -> params.password, p_options -> params.options
75
+ const fieldName = arg.slice(2); // Remove 'p_' prefix
76
+ const value = params?.[fieldName];
77
+ dbParams.push(value !== undefined ? value : null);
78
+ sqlArgs.push(`$${dbParams.length}`);
71
79
  } else {
72
80
  // Unknown arg? Pass null
73
81
  dbParams.push(null);