dzql 0.4.8 → 0.5.0

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/bin/cli.js CHANGED
@@ -230,33 +230,48 @@ CREATE TRIGGER dzql_events_notify
230
230
  }
231
231
 
232
232
  // Generate auth functions (required for WebSocket server)
233
- const authSQL = `-- Authentication Functions
233
+ // This is a fallback for when there's no users entity - otherwise users.sql has these
234
+ const authSQL = `-- Authentication Functions (fallback)
234
235
  -- Required for DZQL WebSocket server
236
+ -- Note: If you have a users entity, auth functions are in users.sql instead
235
237
 
236
238
  -- Enable pgcrypto extension for password hashing
237
239
  CREATE EXTENSION IF NOT EXISTS pgcrypto;
238
240
 
239
241
  -- Register new user
240
- CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT)
242
+ -- p_options: optional JSON object with additional fields to set on the user record
243
+ CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options JSONB DEFAULT NULL)
241
244
  RETURNS JSONB
242
245
  LANGUAGE plpgsql
243
246
  SECURITY DEFINER
244
247
  AS $$
245
248
  DECLARE
246
- user_id INT;
247
- salt TEXT;
248
- hash TEXT;
249
+ v_user_id INT;
250
+ v_salt TEXT;
251
+ v_hash TEXT;
252
+ v_insert_data JSONB;
249
253
  BEGIN
250
254
  -- Generate salt and hash password
251
- salt := gen_salt('bf', 10);
252
- hash := crypt(p_password, salt);
255
+ v_salt := gen_salt('bf', 10);
256
+ v_hash := crypt(p_password, v_salt);
253
257
 
254
- -- Insert user (assumes users table has: id, email, name, password_hash)
255
- INSERT INTO users (email, name, password_hash)
256
- VALUES (p_email, split_part(p_email, '@', 1), hash)
257
- RETURNING id INTO user_id;
258
+ -- Build insert data: options fields + email + password_hash
259
+ v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
260
+ IF p_options IS NOT NULL THEN
261
+ v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
262
+ END IF;
258
263
 
259
- RETURN _profile(user_id);
264
+ -- Dynamic INSERT from JSONB
265
+ EXECUTE (
266
+ SELECT format(
267
+ 'INSERT INTO users (%s) VALUES (%s) RETURNING id',
268
+ string_agg(quote_ident(key), ', '),
269
+ string_agg(quote_nullable(value), ', ')
270
+ )
271
+ FROM jsonb_each_text(v_insert_data) kv(key, value)
272
+ ) INTO v_user_id;
273
+
274
+ RETURN _profile(v_user_id);
260
275
  EXCEPTION
261
276
  WHEN unique_violation THEN
262
277
  RAISE EXCEPTION 'Email already exists' USING errcode = '23505';
@@ -269,10 +284,10 @@ LANGUAGE plpgsql
269
284
  SECURITY DEFINER
270
285
  AS $$
271
286
  DECLARE
272
- user_record RECORD;
287
+ v_user_record RECORD;
273
288
  BEGIN
274
- SELECT id, email, name, password_hash
275
- INTO user_record
289
+ SELECT id, email, password_hash
290
+ INTO v_user_record
276
291
  FROM users
277
292
  WHERE email = p_email;
278
293
 
@@ -280,14 +295,15 @@ BEGIN
280
295
  RAISE EXCEPTION 'Invalid credentials' USING errcode = '28000';
281
296
  END IF;
282
297
 
283
- IF NOT (user_record.password_hash = crypt(p_password, user_record.password_hash)) THEN
298
+ IF NOT (v_user_record.password_hash = crypt(p_password, v_user_record.password_hash)) THEN
284
299
  RAISE EXCEPTION 'Invalid credentials' USING errcode = '28000';
285
300
  END IF;
286
301
 
287
- RETURN _profile(user_record.id);
302
+ RETURN _profile(v_user_record.id);
288
303
  END $$;
289
304
 
290
305
  -- Get user profile (private function, called after login/register)
306
+ -- Returns all columns except sensitive fields
291
307
  CREATE OR REPLACE FUNCTION _profile(p_user_id INT)
292
308
  RETURNS JSONB
293
309
  LANGUAGE sql
@@ -299,8 +315,12 @@ AS $$
299
315
  $$;
300
316
  `;
301
317
 
302
- writeFileSync(resolve(options.output, '002_auth.sql'), authSQL, 'utf-8');
303
- console.log(` ✓ 002_auth.sql`);
318
+ // Only generate 002_auth.sql if there's no users entity (which has its own auth functions)
319
+ const hasUsersEntity = result.results.some(r => r.tableName === 'users');
320
+ if (!hasUsersEntity) {
321
+ writeFileSync(resolve(options.output, '002_auth.sql'), authSQL, 'utf-8');
322
+ console.log(` ✓ 002_auth.sql`);
323
+ }
304
324
 
305
325
  const checksums = {};
306
326
 
@@ -635,7 +635,8 @@ invokej dzql.lookup organisations '{"query": "acme"}'
635
635
  ### Real-time Events
636
636
  - Listen using `ws.onBroadcast((method, params) => {})`
637
637
  - Method format: `{table}:{operation}` (e.g., "venues:update")
638
- - Params include: `{table, op, pk, before, after, user_id, at}`
638
+ - Params include: `{table, op, pk, data, user_id, at}`
639
+ - `data` contains: new state for insert/update, `null` for delete
639
640
  - Target users via notification paths or broadcast to all
640
641
 
641
642
  ### Permissions
@@ -684,8 +685,7 @@ TABLE dzql.events {
684
685
  table_name TEXT NOT NULL,
685
686
  op TEXT NOT NULL, -- 'insert' | 'update' | 'delete'
686
687
  pk JSONB NOT NULL, -- Primary key: {id: 1}
687
- before JSONB, -- Old values (null for insert)
688
- after JSONB, -- New values (null for delete)
688
+ data JSONB, -- Record data (new state for insert/update, null for delete)
689
689
  user_id INT, -- Who made the change
690
690
  notify_users INT[], -- Who to notify (null = all)
691
691
  at TIMESTAMPTZ DEFAULT NOW()
@@ -753,43 +753,46 @@ try {
753
753
  table: 'venues', // Table name
754
754
  op: 'insert', // Operation: 'insert' | 'update' | 'delete'
755
755
  pk: {id: 1}, // Primary key object
756
- before: { // Old values (null for insert)
757
- id: 1,
758
- name: 'Old Name',
759
- address: 'Old Address'
760
- },
761
- after: { // New values (null for delete)
756
+ data: { // Record data (new state for insert/update, null for delete)
762
757
  id: 1,
763
758
  name: 'New Name',
764
759
  address: 'New Address'
765
760
  },
766
761
  user_id: 123, // User who made the change
767
- at: '2025-01-01T12:00:00Z', // Timestamp
768
- notify_users: [1, 2, 3] // Targeted users (null = all authenticated)
762
+ at: '2025-01-01T12:00:00Z' // Timestamp
769
763
  }
770
764
  ```
771
765
 
766
+ **Event data by operation:**
767
+ | Operation | `data` field contains |
768
+ |-----------|----------------------|
769
+ | `insert` | Full new record |
770
+ | `update` | Full updated record (new state only) |
771
+ | `delete` | `null` |
772
+
773
+ **Note:** The `notify_users` field is used internally for routing but is stripped from the broadcast message sent to clients.
774
+
772
775
  ### Using Event Data
773
776
 
774
777
  ```javascript
775
778
  ws.onBroadcast((method, params) => {
779
+ const { table, op, pk, data } = params;
780
+
776
781
  // For insert
777
782
  if (method === 'venues:insert') {
778
- const newRecord = params.after;
779
- // params.before is null
783
+ const newRecord = data;
780
784
  }
781
785
 
782
786
  // For update
783
787
  if (method === 'venues:update') {
784
- const oldRecord = params.before;
785
- const newRecord = params.after;
786
- // Compare to detect what changed
788
+ const updatedRecord = data;
789
+ // Note: only new state is available, not the previous state
787
790
  }
788
791
 
789
792
  // For delete
790
793
  if (method === 'venues:delete') {
791
- const deletedRecord = params.before;
792
- // params.after is null
794
+ // data is null for delete, use pk to identify the deleted record
795
+ const deletedId = pk.id;
793
796
  }
794
797
  });
795
798
  ```
@@ -892,6 +892,25 @@ const result = await ws.api.register_user({
892
892
  });
893
893
  ```
894
894
 
895
+ **With options (for extended registration):**
896
+ ```javascript
897
+ const result = await ws.api.register_user({
898
+ email: 'user@example.com',
899
+ password: 'secure-password',
900
+ options: {
901
+ org_name: 'Acme Corp',
902
+ role: 'admin'
903
+ }
904
+ });
905
+ ```
906
+
907
+ **Parameters:**
908
+ | Field | Type | Required | Description |
909
+ |-------|------|----------|-------------|
910
+ | `email` | string | yes | User email address |
911
+ | `password` | string | yes | User password |
912
+ | `options` | object | no | Additional JSONB data passed to `register_user()` function |
913
+
895
914
  **Returns:**
896
915
  ```javascript
897
916
  {
@@ -902,6 +921,17 @@ const result = await ws.api.register_user({
902
921
  }
903
922
  ```
904
923
 
924
+ **Note:** The `options` parameter requires your `register_user` PostgreSQL function to accept a third parameter:
925
+ ```sql
926
+ CREATE OR REPLACE FUNCTION register_user(
927
+ p_email TEXT,
928
+ p_password TEXT,
929
+ p_options JSONB DEFAULT NULL
930
+ ) RETURNS JSONB AS $$
931
+ -- Access options: p_options->>'org_name'
932
+ $$;
933
+ ```
934
+
905
935
  ### Login
906
936
 
907
937
  ```javascript
@@ -911,8 +941,38 @@ const result = await ws.api.login_user({
911
941
  });
912
942
  ```
913
943
 
944
+ **With options:**
945
+ ```javascript
946
+ const result = await ws.api.login_user({
947
+ email: 'user@example.com',
948
+ password: 'password',
949
+ options: {
950
+ device_id: 'abc123',
951
+ remember_me: true
952
+ }
953
+ });
954
+ ```
955
+
956
+ **Parameters:**
957
+ | Field | Type | Required | Description |
958
+ |-------|------|----------|-------------|
959
+ | `email` | string | yes | User email address |
960
+ | `password` | string | yes | User password |
961
+ | `options` | object | no | Additional JSONB data passed to `login_user()` function |
962
+
914
963
  **Returns:** Same as register
915
964
 
965
+ **Note:** The `options` parameter requires your `login_user` PostgreSQL function to accept a third parameter:
966
+ ```sql
967
+ CREATE OR REPLACE FUNCTION login_user(
968
+ p_email TEXT,
969
+ p_password TEXT,
970
+ p_options JSONB DEFAULT NULL
971
+ ) RETURNS JSONB AS $$
972
+ -- Access options: p_options->>'device_id'
973
+ $$;
974
+ ```
975
+
916
976
  ### Logout
917
977
 
918
978
  ```javascript
@@ -980,27 +1040,34 @@ unsubscribe();
980
1040
  table: 'venues',
981
1041
  op: 'insert' | 'update' | 'delete',
982
1042
  pk: {id: 1}, // Primary key
983
- before: {...}, // Old values (null for insert)
984
- after: {...}, // New values (null for delete)
1043
+ data: {...}, // Record data (new state for insert/update, null for delete)
985
1044
  user_id: 123, // Who made the change
986
- at: '2025-01-01T...', // Timestamp
987
- notify_users: [1, 2] // Who to notify (null = all)
1045
+ at: '2025-01-01T...' // Timestamp
988
1046
  }
989
1047
  ```
990
1048
 
1049
+ **Event data by operation:**
1050
+ | Operation | `data` field contains |
1051
+ |-----------|----------------------|
1052
+ | `insert` | Full new record |
1053
+ | `update` | Full updated record (new state only) |
1054
+ | `delete` | `null` |
1055
+
1056
+ **Note:** The `notify_users` field is used internally for routing but is stripped from the broadcast message sent to clients.
1057
+
991
1058
  ### Event Handling Pattern
992
1059
 
993
1060
  ```javascript
994
1061
  ws.onBroadcast((method, params) => {
995
- const data = params.after || params.before;
1062
+ const { table, op, pk, data } = params;
996
1063
 
997
1064
  if (method === 'todos:insert') {
998
1065
  state.todos.push(data);
999
1066
  } else if (method === 'todos:update') {
1000
- const idx = state.todos.findIndex(t => t.id === data.id);
1067
+ const idx = state.todos.findIndex(t => t.id === pk.id);
1001
1068
  if (idx !== -1) state.todos[idx] = data;
1002
1069
  } else if (method === 'todos:delete') {
1003
- state.todos = state.todos.filter(t => t.id !== data.id);
1070
+ state.todos = state.todos.filter(t => t.id !== pk.id);
1004
1071
  }
1005
1072
 
1006
1073
  render();
@@ -82,6 +82,47 @@ async function login() {
82
82
  </template>
83
83
  ```
84
84
 
85
+ **Registration with options (e.g., organisation name):**
86
+ ```vue
87
+ <script setup>
88
+ import { ref } from 'vue'
89
+ import { useWsStore } from 'dzql/client/stores'
90
+
91
+ const wsStore = useWsStore()
92
+ const email = ref('')
93
+ const password = ref('')
94
+ const orgName = ref('')
95
+
96
+ async function register() {
97
+ try {
98
+ await wsStore.register({
99
+ email: email.value,
100
+ password: password.value,
101
+ options: { org_name: orgName.value }
102
+ })
103
+ } catch (err) {
104
+ alert(err.message)
105
+ }
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <form @submit.prevent="register">
111
+ <input v-model="email" type="email" placeholder="Email" required />
112
+ <input v-model="password" type="password" placeholder="Password" required />
113
+ <input v-model="orgName" type="text" placeholder="Organisation Name" />
114
+ <button type="submit">Register</button>
115
+ </form>
116
+ </template>
117
+ ```
118
+
119
+ The `options` parameter allows passing additional JSONB data to the `register_user` and `login_user` PostgreSQL functions. This is useful for:
120
+ - Organisation name during registration
121
+ - Device ID for login tracking
122
+ - Any custom fields your auth functions support
123
+
124
+ See [API Reference - Authentication](./api.md#authentication) for details on configuring your PostgreSQL functions.
125
+
85
126
  ## 4. Setup main.js
86
127
 
87
128
  **src/main.js:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
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",
package/src/client/ws.js CHANGED
@@ -23,6 +23,13 @@
23
23
  * password: 'password123'
24
24
  * });
25
25
  *
26
+ * // Register with options (e.g., organisation name)
27
+ * const session = await ws.api.register_user({
28
+ * email: 'user@example.com',
29
+ * password: 'password123',
30
+ * options: { org_name: 'Acme Corp' }
31
+ * });
32
+ *
26
33
  * // CRUD operations
27
34
  * const venue = await ws.api.get.venues({ id: 1 });
28
35
  * const created = await ws.api.save.venues({ name: 'New Venue' });
@@ -28,6 +28,9 @@ export class AuthCodegen {
28
28
  }
29
29
 
30
30
  return [
31
+ '-- Enable pgcrypto extension for password hashing',
32
+ 'CREATE EXTENSION IF NOT EXISTS pgcrypto;',
33
+ '',
31
34
  this._generateProfileFunction(),
32
35
  this._generateRegisterFunction(),
33
36
  this._generateLoginFunction()
@@ -57,16 +60,16 @@ $$;`;
57
60
 
58
61
  /**
59
62
  * Generate register_user function
60
- * Supports optional extra fields via JSON parameter
63
+ * Supports optional fields via JSON parameter
61
64
  * @private
62
65
  */
63
66
  _generateRegisterFunction() {
64
67
  return `-- ============================================================================
65
68
  -- Auth: register_user function for ${this.tableName}
66
- -- p_extra: optional JSON object with additional fields to set on the user record
69
+ -- p_options: optional JSON object with additional fields to set on the user record
67
70
  -- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
68
71
  -- ============================================================================
69
- CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_extra JSONB DEFAULT '{}')
72
+ CREATE OR REPLACE FUNCTION register_user(p_email TEXT, p_password TEXT, p_options JSONB DEFAULT NULL)
70
73
  RETURNS JSONB
71
74
  LANGUAGE plpgsql
72
75
  SECURITY DEFINER
@@ -81,9 +84,11 @@ BEGIN
81
84
  v_salt := gen_salt('bf', 10);
82
85
  v_hash := crypt(p_password, v_salt);
83
86
 
84
- -- Build insert data: extra fields + email + password_hash (extra cannot override core fields)
85
- v_insert_data := (p_extra - 'id' - 'email' - 'password_hash' - 'password')
86
- || jsonb_build_object('email', p_email, 'password_hash', v_hash);
87
+ -- Build insert data: options fields + email + password_hash (options cannot override core fields)
88
+ v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
89
+ IF p_options IS NOT NULL THEN
90
+ v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
91
+ END IF;
87
92
 
88
93
  -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
89
94
  EXECUTE (
@@ -5,12 +5,10 @@
5
5
  create extension if not exists pgcrypto;
6
6
 
7
7
  -- === Users Table ===
8
- -- Core auth table with optional name field
9
- -- Applications can add additional columns as needed
8
+ -- Minimal auth table - applications can add columns via migrations
10
9
  -- Note: created_at is tracked via the action log, not here
11
10
  create table if not exists users (
12
11
  id serial primary key,
13
- name text,
14
12
  email text unique not null,
15
13
  password_hash text not null
16
14
  );
@@ -18,9 +16,9 @@ create table if not exists users (
18
16
  -- === Auth Functions ===
19
17
 
20
18
  -- Register new user
21
- -- p_extra: optional JSON object with additional fields to set on the user record
19
+ -- p_options: optional JSON object with additional fields to set on the user record
22
20
  -- Example: register_user('test@example.com', 'password', '{"name": "Test User"}')
23
- create or replace function register_user(p_email text, p_password text, p_extra jsonb default '{}')
21
+ create or replace function register_user(p_email text, p_password text, p_options jsonb default null)
24
22
  returns jsonb
25
23
  language plpgsql
26
24
  security definer
@@ -35,9 +33,11 @@ begin
35
33
  v_salt := gen_salt('bf', 10);
36
34
  v_hash := crypt(p_password, v_salt);
37
35
 
38
- -- Build insert data: extra fields + email + password_hash (extra cannot override core fields)
39
- v_insert_data := (p_extra - 'id' - 'email' - 'password_hash' - 'password')
40
- || jsonb_build_object('email', p_email, 'password_hash', v_hash);
36
+ -- Build insert data: options fields + email + password_hash (options cannot override core fields)
37
+ v_insert_data := jsonb_build_object('email', p_email, 'password_hash', v_hash);
38
+ if p_options is not null then
39
+ v_insert_data := (p_options - 'id' - 'email' - 'password_hash' - 'password') || v_insert_data;
40
+ end if;
41
41
 
42
42
  -- Dynamic INSERT from JSONB (same pattern as compiled save functions)
43
43
  execute (
package/src/server/db.js CHANGED
@@ -54,7 +54,13 @@ export async function setCache(key, data) {
54
54
  }
55
55
 
56
56
  // Auth helpers
57
- export async function callAuthFunction(method, email, password) {
57
+ export async function callAuthFunction(method, email, password, options = null) {
58
+ if (options !== null) {
59
+ const result = await sql`
60
+ SELECT ${sql(method)}(${email}, ${password}, ${options}) as result
61
+ `;
62
+ return result[0].result;
63
+ }
58
64
  const result = await sql`
59
65
  SELECT ${sql(method)}(${email}, ${password}) as result
60
66
  `;
@@ -380,7 +386,7 @@ export const db = {
380
386
  // Special handling for auth functions that don't require userId
381
387
  if (prop === 'register_user' || prop === 'login_user') {
382
388
  // For auth functions, first param is the args object
383
- return callAuthFunction(prop, userIdOrArgs.email, userIdOrArgs.password);
389
+ return callAuthFunction(prop, userIdOrArgs.email, userIdOrArgs.password, userIdOrArgs.options || null);
384
390
  }
385
391
 
386
392
  // For other functions, userId is required as first parameter
package/src/server/ws.js CHANGED
@@ -231,6 +231,7 @@ export function createRPCHandler(customHandlers = {}) {
231
231
  "login_user",
232
232
  params.email,
233
233
  params.password,
234
+ params.options || null,
234
235
  );
235
236
 
236
237
  // On successful auth, set user_id on WebSocket connection
@@ -268,6 +269,7 @@ export function createRPCHandler(customHandlers = {}) {
268
269
  "register_user",
269
270
  params.email,
270
271
  params.password,
272
+ params.options || null,
271
273
  );
272
274
 
273
275
  // On successful registration, set user_id on WebSocket connection