dzql 0.6.2 → 0.6.5

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/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # DZQL
2
+
3
+ Database-first real-time framework with TypeScript support.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ bun create dzql my-app
9
+ cd my-app
10
+ bun install
11
+ bun run db:rebuild
12
+ bun run dev
13
+ ```
14
+
15
+ ## Documentation
16
+
17
+ See the [full documentation](./docs/README.md) for:
18
+
19
+ - [Project Setup Guide](./docs/project-setup.md)
20
+ - [AI Assistant Guide](./docs/for_ai.md)
21
+
22
+ ## Package Exports
23
+
24
+ ```typescript
25
+ import { createServer } from 'dzql'; // Runtime server
26
+ import { ws } from 'dzql/client'; // WebSocket client
27
+ import { compile } from 'dzql/compiler'; // CLI compiler
28
+ import { DzqlNamespace } from 'dzql/namespace'; // Direct DB access
29
+ ```
30
+
31
+ ## License
32
+
33
+ MIT
package/docs/for_ai.md CHANGED
@@ -535,28 +535,24 @@ When a client connects to the WebSocket server, it immediately receives a `conne
535
535
  ### WebSocketManager API
536
536
 
537
537
  ```typescript
538
- import { WebSocketManager } from 'dzql/client';
538
+ import { ws } from '@generated/client/ws';
539
539
 
540
- const ws = new WebSocketManager();
541
- await ws.connect('ws://localhost:3000/ws');
540
+ await ws.connect('/ws');
542
541
 
543
- // Connection state properties
544
- ws.ready // boolean - true after connection:ready received
545
- ws.user // user profile object or null if anonymous
542
+ // Authentication via typed API
543
+ const user = await ws.api.login_user({ email: '...', password: '...' });
544
+ // Token is returned in response - store in localStorage
545
+ if (user.token) {
546
+ localStorage.setItem('dzql_token', user.token);
547
+ }
546
548
 
547
- // Register callback for ready state (called immediately if already ready)
548
- const unsubscribe = ws.onReady((user) => {
549
- if (user) {
550
- console.log('Authenticated as:', user.email);
551
- } else {
552
- console.log('Anonymous connection');
553
- }
554
- });
549
+ const newUser = await ws.api.register_user({ name: '...', email: '...', password: '...' });
550
+ // Token is returned in response
555
551
 
556
- // Authentication methods
557
- await ws.login({ email: '...', password: '...' }); // Stores token in localStorage
558
- await ws.register({ email: '...', password: '...' }); // Stores token in localStorage
559
- await ws.logout(); // Clears token, user state, and reconnects
552
+ // Logout - clear token and disconnect
553
+ localStorage.removeItem('dzql_token');
554
+ ws.disconnect();
555
+ await ws.connect('/ws'); // Reconnect without token
560
556
  ```
561
557
 
562
558
  ### Vue/Pinia Usage Pattern
@@ -231,7 +231,7 @@ export default defineConfig({
231
231
 
232
232
  ```typescript
233
233
  import { ref } from 'vue'
234
- import { ws } from '@generated/client'
234
+ import { ws } from '@generated/client/ws'
235
235
 
236
236
  const ready = ref(false)
237
237
  const user = ref<any>(null)
@@ -242,16 +242,9 @@ export function useDzql() {
242
242
  try {
243
243
  connectionError.value = null
244
244
  ready.value = false
245
-
246
- ws.onReady((profile: any) => {
247
- if (!profile && localStorage.getItem('token')) {
248
- localStorage.removeItem('token')
249
- }
250
- user.value = profile
251
- ready.value = true
252
- })
253
-
254
- await ws.connect(url)
245
+
246
+ await ws.connect(url || '/ws')
247
+ ready.value = true
255
248
  } catch (e: any) {
256
249
  connectionError.value = e.message
257
250
  throw e
@@ -259,24 +252,32 @@ export function useDzql() {
259
252
  }
260
253
 
261
254
  async function login(email: string, password: string) {
262
- const result = await ws.login({ email, password })
255
+ const result = await ws.api.login_user({ email, password }) as any
263
256
  if (result?.user_id) {
264
257
  user.value = result
258
+ if (result.token) {
259
+ localStorage.setItem('dzql_token', result.token)
260
+ }
265
261
  }
266
262
  return result
267
263
  }
268
264
 
269
265
  async function register(name: string, email: string, password: string) {
270
- const result = await ws.register({ name, email, password })
266
+ const result = await ws.api.register_user({ name, email, password }) as any
271
267
  if (result?.user_id) {
272
268
  user.value = result
269
+ if (result.token) {
270
+ localStorage.setItem('dzql_token', result.token)
271
+ }
273
272
  }
274
273
  return result
275
274
  }
276
275
 
277
276
  async function logout() {
278
- await ws.logout()
277
+ localStorage.removeItem('dzql_token')
279
278
  user.value = null
279
+ ws.disconnect()
280
+ await connect()
280
281
  }
281
282
 
282
283
  return { ws, ready, user, connectionError, connect, login, register, logout }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.2",
3
+ "version": "0.6.5",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,17 +10,36 @@
10
10
  "bin": {
11
11
  "dzql": "./src/cli/index.ts"
12
12
  },
13
- "scripts": {
14
- "dev": "bun run src/cli/index.ts",
15
- "test": "bun test",
16
- "build": "bun build ./src/cli/index.ts --outdir ./dist/cli --target node"
17
- },
18
13
  "exports": {
19
14
  ".": "./src/runtime/index.ts",
20
15
  "./client": "./src/client/index.ts",
21
16
  "./compiler": "./src/cli/index.ts",
22
17
  "./namespace": "./src/runtime/namespace.ts"
23
18
  },
19
+ "files": [
20
+ "src",
21
+ "docs",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "test": "bun test"
26
+ },
27
+ "keywords": [
28
+ "postgresql",
29
+ "postgres",
30
+ "websocket",
31
+ "real-time",
32
+ "realtime",
33
+ "database",
34
+ "bun",
35
+ "vue",
36
+ "pinia",
37
+ "typescript",
38
+ "compiler",
39
+ "codegen"
40
+ ],
41
+ "author": "Peter Bunyan",
42
+ "license": "MIT",
24
43
  "dependencies": {
25
44
  "postgres": "^3.4.3",
26
45
  "dotenv": "^16.4.5",
@@ -34,5 +53,8 @@
34
53
  },
35
54
  "peerDependencies": {
36
55
  "typescript": "^5.0.0"
56
+ },
57
+ "engines": {
58
+ "bun": ">=1.0.0"
37
59
  }
38
60
  }
@@ -35,8 +35,7 @@ export function generateClientSDK(manifest: Manifest): string {
35
35
  if (isSubscription) {
36
36
  // subscribe_venue_detail -> VenueDetailParams
37
37
  paramType = `${pascalEntity}Params`;
38
- returnType = 'void';
39
- return ` ${funcName}: (params: ${paramType}, callback: (data: unknown) => void) => Promise<() => ${returnType}>;`;
38
+ return ` ${funcName}: (params: ${paramType}, callback: (data: unknown) => void) => Promise<{ data: unknown; subscription_id: string; schema: unknown; unsubscribe: () => Promise<void> }>;`;
40
39
  } else if (op === 'get' && entityExists) {
41
40
  // get_venue_detail (subscribable getter) vs get_venues (entity getter)
42
41
  if (manifest.subscribables?.[entity]) {
@@ -78,16 +77,16 @@ ${apiMethods}
78
77
 
79
78
  /** Extended WebSocket manager with typed API */
80
79
  export class GeneratedWebSocketManager extends WebSocketManager {
81
- api: DzqlAPI;
80
+ declare api: DzqlAPI;
82
81
 
83
- constructor(options: { url?: string; reconnect?: boolean } = {}) {
82
+ constructor(options: { maxReconnectAttempts?: number; tokenName?: string } = {}) {
84
83
  super(options);
85
84
  this.api = {
86
85
  ${regularFunctions.map(([funcName]) =>
87
- ` ${funcName}: (params: Record<string, unknown>) => this.call('${funcName}', params),`
86
+ ` ${funcName}: (params: any) => this.call('${funcName}', params),`
88
87
  ).join('\n')}
89
88
  ${subscriptionFunctions.map(([funcName]) =>
90
- ` ${funcName}: (params: Record<string, unknown>, callback: (data: unknown) => void) => this.subscribe('${funcName}', params, callback),`
89
+ ` ${funcName}: (params: any, callback: (data: any) => void) => this.subscribe('${funcName}', params, callback),`
91
90
  ).join('\n')}
92
91
  } as DzqlAPI;
93
92
  }
@@ -223,7 +223,7 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
223
223
  // Handle special @user_id root key
224
224
  const rootKey = sub.root.key;
225
225
  const isUserIdRoot = rootKey === '@user_id';
226
- const rootWhereValue = isUserIdRoot ? 'p_user_id' : `v_${rootKey}`;
226
+ const isList = !rootKey; // No key means it's a list subscribable
227
227
 
228
228
  // Build root select expression excluding hidden fields
229
229
  const rootSelectExpr = buildVisibleRowJson('root', sub.root.entity, entities);
@@ -239,7 +239,67 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
239
239
  scopeTables: sub.scopeTables
240
240
  }).replace(/'/g, "''"); // Escape single quotes for SQL
241
241
 
242
- return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
242
+ // Build WHERE clause based on root filter and key
243
+ const whereConditions: string[] = [];
244
+ if (rootKey) {
245
+ const rootWhereValue = isUserIdRoot ? 'p_user_id' : `v_${rootKey}`;
246
+ whereConditions.push(`root.id = ${rootWhereValue}`);
247
+ }
248
+ if (sub.root.filter) {
249
+ for (const [field, value] of Object.entries(sub.root.filter)) {
250
+ if (typeof value === 'boolean') {
251
+ whereConditions.push(`root.${field} = ${value}`);
252
+ } else if (typeof value === 'string') {
253
+ whereConditions.push(`root.${field} = '${value}'`);
254
+ } else {
255
+ whereConditions.push(`root.${field} = ${value}`);
256
+ }
257
+ }
258
+ }
259
+ const whereClause = whereConditions.length > 0
260
+ ? `WHERE ${whereConditions.join(' AND ')}`
261
+ : '';
262
+
263
+ if (isList) {
264
+ // List subscribable - return array of records with nested includes
265
+ return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
266
+ p_params JSONB,
267
+ p_user_id INT
268
+ ) RETURNS JSONB
269
+ LANGUAGE plpgsql
270
+ SECURITY DEFINER
271
+ SET search_path = dzql_v2, public
272
+ AS $$
273
+ DECLARE
274
+ ${paramDecls}
275
+ v_data JSONB;
276
+ BEGIN
277
+ -- Extract parameters
278
+ ${paramExtracts}
279
+
280
+ -- Check access control
281
+ IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
282
+ RAISE EXCEPTION 'permission_denied';
283
+ END IF;
284
+
285
+ -- Build list of documents with nested relations
286
+ SELECT COALESCE(jsonb_agg(
287
+ to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
288
+ ), '[]'::jsonb)
289
+ INTO v_data
290
+ FROM ${sub.root.entity} root
291
+ ${whereClause};
292
+
293
+ -- Return data with embedded schema for atomic updates
294
+ RETURN jsonb_build_object(
295
+ '${sub.root.entity}', v_data,
296
+ 'schema', '${schemaJson}'::jsonb
297
+ );
298
+ END;
299
+ $$;`;
300
+ } else {
301
+ // Single record subscribable
302
+ return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
243
303
  p_params JSONB,
244
304
  p_user_id INT
245
305
  ) RETURNS JSONB
@@ -265,7 +325,7 @@ ${paramExtracts}
265
325
  )
266
326
  INTO v_data
267
327
  FROM ${sub.root.entity} root
268
- WHERE root.id = ${rootWhereValue};
328
+ ${whereClause};
269
329
 
270
330
  -- Return data with embedded schema for atomic updates
271
331
  RETURN jsonb_build_object(
@@ -274,6 +334,7 @@ ${paramExtracts}
274
334
  );
275
335
  END;
276
336
  $$;`;
337
+ }
277
338
  }
278
339
 
279
340
  function singularize(name: string): string {
@@ -59,8 +59,8 @@ export function generateSubscribableStore(manifest: Manifest, subName: string):
59
59
 
60
60
  cases.push(
61
61
  ` case '${entityName}':\n` +
62
- ` if (event.data && event.data.${fkField}) {\n` +
63
- ` const parent = doc.${level1Rel}?.find((p: { id: number }) => p.id === event.data.${fkField});\n` +
62
+ ` if (event.data && (event.data as any).${fkField}) {\n` +
63
+ ` const parent = (doc.${level1Rel} as any[])?.find((p: any) => p.id === (event.data as any).${fkField});\n` +
64
64
  ` if (parent && parent.${key}) {\n` +
65
65
  ` handleArrayPatch(parent.${key}, event);\n` +
66
66
  ` }\n` +
@@ -164,15 +164,15 @@ ${patchCases}
164
164
  }
165
165
  }
166
166
 
167
- function handleArrayPatch(arr: unknown[] | undefined, event: PatchEvent): void {
167
+ function handleArrayPatch(arr: any, event: PatchEvent): void {
168
168
  if (!arr || !Array.isArray(arr)) return;
169
169
  const pkValue = event.pk?.id;
170
- const idx = arr.findIndex((i: unknown) => (i as { id: number }).id === pkValue);
170
+ const idx = arr.findIndex((i: any) => i?.id === pkValue);
171
171
 
172
172
  if (event.op === 'insert') {
173
173
  if (idx === -1 && event.data) arr.push(event.data);
174
174
  } else if (event.op === 'update') {
175
- if (idx !== -1 && event.data) Object.assign(arr[idx] as object, event.data);
175
+ if (idx !== -1 && event.data) Object.assign(arr[idx], event.data);
176
176
  } else if (event.op === 'delete') {
177
177
  if (idx !== -1) arr.splice(idx, 1);
178
178
  }
package/src/runtime/ws.ts CHANGED
@@ -3,6 +3,9 @@ import { handleRequest } from "./server.js"; // The secure router
3
3
  import { verifyToken, signToken } from "./auth.js";
4
4
  import { Database } from "./db.js";
5
5
 
6
+ // WebSocket configuration
7
+ const WS_MAX_MESSAGE_SIZE = parseInt(process.env.WS_MAX_MESSAGE_SIZE || "1048576", 10); // 1MB default
8
+
6
9
  interface WSContext {
7
10
  id: string;
8
11
  userId?: number;
@@ -17,13 +20,17 @@ export class WebSocketServer {
17
20
 
18
21
  constructor(db: Database) {
19
22
  this.db = db;
20
- // Start heartbeat interval
21
- setInterval(() => this.heartbeat(), 30000);
22
23
  }
23
24
 
24
- // Bun.serve websocket handler hooks
25
- get handlers() {
25
+ // Bun.serve websocket configuration object
26
+ get websocket() {
26
27
  return {
28
+ // WebSocket options for Bun
29
+ perMessageDeflate: true,
30
+ maxPayloadLength: WS_MAX_MESSAGE_SIZE,
31
+ idleTimeout: 0, // No idle timeout - realtime connections stay open indefinitely
32
+
33
+ // Handler hooks
27
34
  open: (ws: ServerWebSocket<WSContext>) => this.handleOpen(ws),
28
35
  message: (ws: ServerWebSocket<WSContext>, message: string) => this.handleMessage(ws, message),
29
36
  close: (ws: ServerWebSocket<WSContext>) => this.handleClose(ws),
@@ -31,6 +38,11 @@ export class WebSocketServer {
31
38
  };
32
39
  }
33
40
 
41
+ // Legacy alias for backwards compatibility
42
+ get handlers() {
43
+ return this.websocket;
44
+ }
45
+
34
46
  private async handleOpen(ws: ServerWebSocket<WSContext>) {
35
47
  const id = Math.random().toString(36).slice(2);
36
48
  const token = ws.data?.token;
@@ -148,6 +160,8 @@ export class WebSocketServer {
148
160
  // Auto-generate token for auth methods
149
161
  if (req.method === 'login_user' || req.method === 'register_user') {
150
162
  const token = await signToken({ user_id: result.user_id, role: 'user' });
163
+ // Update connection's userId for subsequent calls
164
+ ws.data.userId = result.user_id;
151
165
  // Return profile + token
152
166
  ws.send(JSON.stringify({ id: req.id, result: { ...result, token } }));
153
167
  } else {
@@ -171,17 +185,6 @@ export class WebSocketServer {
171
185
  console.log(`[WS] Client ${ws.data.id} disconnected`);
172
186
  }
173
187
 
174
- private heartbeat() {
175
- const now = Date.now();
176
- for (const [id, ws] of this.connections) {
177
- if (now - ws.data.lastPing > 60000) {
178
- console.log(`[WS] Client ${id} timed out`);
179
- ws.close();
180
- this.connections.delete(id);
181
- }
182
- }
183
- }
184
-
185
188
  public broadcast(message: string) {
186
189
  // Use Bun's native publish for efficiency
187
190
  // 'broadcast' topic is subscribed by all on connect
package/.env.sample DELETED
@@ -1,28 +0,0 @@
1
- # TZQL Environment Variables
2
- # Copy this file to .env and modify as needed
3
-
4
- # ===================
5
- # Server (Runtime)
6
- # ===================
7
-
8
- # PostgreSQL connection string
9
- DATABASE_URL=postgres://user:password@localhost:5432/database
10
-
11
- # Server port
12
- PORT=3000
13
-
14
- # Path to compiled manifest (relative to working directory)
15
- MANIFEST_PATH=./dist/runtime/manifest.json
16
-
17
- # JWT secret for authentication (CHANGE IN PRODUCTION!)
18
- JWT_SECRET=your-secure-secret-key-here
19
-
20
- # ===================
21
- # Client (Browser)
22
- # ===================
23
- # These are injected at build time by your bundler (Vite, Webpack, etc.)
24
-
25
- # Token name for localStorage (Vite: prefix with VITE_)
26
- # VITE_TZQL_TOKEN_NAME=myapp_token
27
- # Or for other bundlers:
28
- # TZQL_TOKEN_NAME=myapp_token
package/compose.yml DELETED
@@ -1,28 +0,0 @@
1
- services:
2
- postgres:
3
- image: postgres:16-alpine
4
- container_name: tzql-test-db
5
- environment:
6
- POSTGRES_USER: dzql_test
7
- POSTGRES_PASSWORD: dzql_test
8
- POSTGRES_DB: dzql_test
9
- ports:
10
- - "5433:5432" # Map container's 5432 to host's 5433
11
- volumes:
12
- - tzql-test-data:/var/lib/postgresql/data
13
- healthcheck:
14
- test: ["CMD-SHELL", "pg_isready -U dzql_test -d dzql_test"]
15
- interval: 5s
16
- timeout: 5s
17
- retries: 5
18
- command:
19
- - "postgres"
20
- - "-c"
21
- - "log_statement=all"
22
- - "-c"
23
- - "log_destination=stderr"
24
- - "-c"
25
- - "logging_collector=off"
26
-
27
- volumes:
28
- tzql-test-data:
package/examples/blog.ts DELETED
@@ -1,50 +0,0 @@
1
- // TZQL Entity Definition Example
2
-
3
- export const entities = {
4
- posts: {
5
- schema: {
6
- id: 'serial PRIMARY KEY',
7
- title: 'text NOT NULL',
8
- content: 'text',
9
- author_id: 'int NOT NULL', // In a real app, this would reference users(id)
10
- created_at: 'timestamptz DEFAULT now()'
11
- },
12
- permissions: {
13
- view: [], // Public
14
- create: ['@author_id == @user_id'], // Only create for self
15
- update: ['@author_id == @user_id'], // Only owner
16
- delete: ['@author_id == @user_id'] // Only owner
17
- },
18
- graphRules: {
19
- on_create: {
20
- actions: [
21
- { type: 'reactor', name: 'notify_subscribers', params: { post_id: '@id' } }
22
- ]
23
- }
24
- }
25
- },
26
- comments: {
27
- schema: {
28
- id: 'serial PRIMARY KEY',
29
- post_id: 'int NOT NULL REFERENCES posts(id) ON DELETE CASCADE',
30
- content: 'text NOT NULL',
31
- author_id: 'int NOT NULL'
32
- },
33
- permissions: {
34
- view: [],
35
- create: [],
36
- delete: ['@author_id == @user_id']
37
- }
38
- }
39
- };
40
-
41
- export const subscribables = {
42
- post_detail: {
43
- params: { post_id: 'int' },
44
- root: { entity: 'posts', key: 'post_id' },
45
- includes: {
46
- comments: { entity: 'comments', filter: { post_id: '@id' } }
47
- },
48
- scopeTables: ['posts', 'comments']
49
- }
50
- };
@@ -1,18 +0,0 @@
1
- // TZQL Entity Definition Example (INVALID)
2
-
3
- export const entities = {
4
- posts: {
5
- schema: { id: 'serial PRIMARY KEY' },
6
- permissions: {}
7
- }
8
- };
9
-
10
- export const subscribables = {
11
- broken_feed: {
12
- params: {},
13
- root: { entity: 'posts' },
14
- includes: {
15
- comments: { entity: 'missing_table' } // <--- Error: 'missing_table' does not exist
16
- }
17
- }
18
- };