dzql 0.5.33 → 0.6.1
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/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +309 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +653 -0
- package/docs/project-setup.md +456 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +166 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { loadDomain } from "./compiler/loader.js";
|
|
3
|
+
import { analyzeDomain } from "./compiler/analyzer.js";
|
|
4
|
+
import { generateIR } from "./compiler/ir.js";
|
|
5
|
+
import { generateCoreSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
|
|
6
|
+
import { generateSubscribableSQL } from "./codegen/subscribable_sql.js";
|
|
7
|
+
import { generateManifest } from "./codegen/manifest.js";
|
|
8
|
+
import { generateSubscribableStore } from "./codegen/subscribable_store.js";
|
|
9
|
+
import { generateClientSDK } from "./codegen/client.js";
|
|
10
|
+
import { writeFileSync, mkdirSync, copyFileSync, rmSync } from "fs";
|
|
11
|
+
import { resolve, dirname } from "path";
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
let command = args[0];
|
|
15
|
+
let input = args[1];
|
|
16
|
+
let outputDir = "dist"; // Default output directory
|
|
17
|
+
|
|
18
|
+
// If first arg looks like a file (ends with .ts or .js), treat it as compile target
|
|
19
|
+
if (command && (command.endsWith('.ts') || command.endsWith('.js'))) {
|
|
20
|
+
input = command;
|
|
21
|
+
command = 'compile';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Parse optional output directory flag
|
|
25
|
+
const outputFlagIndex = args.indexOf('-o');
|
|
26
|
+
const longOutputFlagIndex = args.indexOf('--output');
|
|
27
|
+
|
|
28
|
+
if (outputFlagIndex > -1 && args[outputFlagIndex + 1]) {
|
|
29
|
+
outputDir = args[outputFlagIndex + 1];
|
|
30
|
+
} else if (longOutputFlagIndex > -1 && args[longOutputFlagIndex + 1]) {
|
|
31
|
+
outputDir = args[longOutputFlagIndex + 1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
console.log("DZQL Compiler v0.6.0");
|
|
36
|
+
|
|
37
|
+
if (command === "compile") {
|
|
38
|
+
if (!input) {
|
|
39
|
+
console.error("Usage: dzql <file> or dzql compile <file>");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Phase 1: Load & Analyze
|
|
45
|
+
const fullInputPath = resolve(process.cwd(), input);
|
|
46
|
+
|
|
47
|
+
// Clean Output Directory
|
|
48
|
+
const absOutputDir = resolve(process.cwd(), outputDir);
|
|
49
|
+
console.log(`[Compiler] Cleaning ${absOutputDir}...`);
|
|
50
|
+
try {
|
|
51
|
+
rmSync(absOutputDir, { recursive: true, force: true });
|
|
52
|
+
} catch (e) { /* ignore */ }
|
|
53
|
+
|
|
54
|
+
const domain = await loadDomain(fullInputPath);
|
|
55
|
+
console.log("[Compiler] Domain loaded.");
|
|
56
|
+
|
|
57
|
+
const errors = analyzeDomain(domain);
|
|
58
|
+
if (errors.length > 0) {
|
|
59
|
+
console.error("[Compiler] Validation Failed:");
|
|
60
|
+
errors.forEach(err => console.error(` - ${err}`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Phase 2: Generate IR
|
|
65
|
+
const ir = generateIR(domain);
|
|
66
|
+
console.log(`[Compiler] IR Generated. Subscribables: ${Object.keys(ir.subscribables).join(', ')}`);
|
|
67
|
+
|
|
68
|
+
// Phase 3: Generate SQL
|
|
69
|
+
const coreSQL = generateCoreSQL();
|
|
70
|
+
const entitySQL: string[] = [];
|
|
71
|
+
for (const [name, entityIR] of Object.entries(ir.entities)) {
|
|
72
|
+
entitySQL.push(generateSchemaSQL(name, entityIR));
|
|
73
|
+
// Skip CRUD generation for unmanaged entities (e.g., junction tables)
|
|
74
|
+
if (entityIR.managed !== false) {
|
|
75
|
+
entitySQL.push(generateEntitySQL(name, entityIR));
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate subscribable SQL functions
|
|
82
|
+
const subscribableSQL: string[] = [];
|
|
83
|
+
for (const [name, subIR] of Object.entries(ir.subscribables)) {
|
|
84
|
+
console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
|
|
85
|
+
subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Collect custom functions SQL
|
|
89
|
+
const customFunctionSQL: string[] = [];
|
|
90
|
+
for (const fn of ir.customFunctions) {
|
|
91
|
+
console.log(`[Compiler] Adding custom function: ${fn.name}`);
|
|
92
|
+
customFunctionSQL.push(fn.sql);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Phase 4: Generate Manifest
|
|
96
|
+
const manifest = generateManifest(ir);
|
|
97
|
+
|
|
98
|
+
// --- OUTPUT GENERATION ---
|
|
99
|
+
|
|
100
|
+
// 1. Database
|
|
101
|
+
const dbDir = resolve(outputDir, "db/migrations");
|
|
102
|
+
mkdirSync(dbDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
writeFileSync(resolve(dbDir, `000_core.sql`), coreSQL);
|
|
105
|
+
const timestamp = new Date().toISOString().replace(/[:.-]/g, '');
|
|
106
|
+
|
|
107
|
+
// Combine entity SQL with custom functions
|
|
108
|
+
const schemaContent = customFunctionSQL.length > 0
|
|
109
|
+
? entitySQL.join('\n') + '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n')
|
|
110
|
+
: entitySQL.join('\n');
|
|
111
|
+
writeFileSync(resolve(dbDir, `${timestamp}_schema.sql`), schemaContent);
|
|
112
|
+
|
|
113
|
+
if (customFunctionSQL.length > 0) {
|
|
114
|
+
console.log(`[Generated] ${customFunctionSQL.length} Custom Functions`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Write subscribable SQL
|
|
118
|
+
if (subscribableSQL.length > 0) {
|
|
119
|
+
writeFileSync(resolve(dbDir, `${timestamp}_subscribables.sql`), subscribableSQL.join('\n\n'));
|
|
120
|
+
console.log(`[Generated] ${subscribableSQL.length} Subscribable SQL functions`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`[Generated] DB Migrations in ${dbDir}`);
|
|
124
|
+
|
|
125
|
+
// 2. Runtime
|
|
126
|
+
const runtimeDir = resolve(outputDir, "runtime");
|
|
127
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
128
|
+
writeFileSync(resolve(runtimeDir, `manifest.json`), JSON.stringify(manifest, null, 2));
|
|
129
|
+
console.log(`[Generated] Runtime Manifest in ${runtimeDir}`);
|
|
130
|
+
|
|
131
|
+
// 3. Client SDK (TypeScript)
|
|
132
|
+
const clientDir = resolve(outputDir, "client");
|
|
133
|
+
mkdirSync(clientDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
// Generate Core SDK as TypeScript
|
|
136
|
+
const clientCode = generateClientSDK(manifest);
|
|
137
|
+
writeFileSync(resolve(clientDir, `ws.ts`), clientCode);
|
|
138
|
+
|
|
139
|
+
// Generate Index
|
|
140
|
+
writeFileSync(resolve(clientDir, `index.ts`), `export * from './ws.js';`);
|
|
141
|
+
|
|
142
|
+
console.log(`[Generated] Client SDK in ${clientDir}`);
|
|
143
|
+
|
|
144
|
+
// 4. Stores (TypeScript)
|
|
145
|
+
const storeDir = resolve(clientDir, "stores");
|
|
146
|
+
mkdirSync(storeDir, { recursive: true });
|
|
147
|
+
|
|
148
|
+
for (const subName of Object.keys(ir.subscribables)) {
|
|
149
|
+
const storeCode = generateSubscribableStore(manifest, subName);
|
|
150
|
+
const fileName = `use${subName.replace(/(^|_)([a-z])/g, (g) => g.at(-1)!.toUpperCase())}Store.ts`;
|
|
151
|
+
writeFileSync(resolve(storeDir, fileName), storeCode);
|
|
152
|
+
}
|
|
153
|
+
console.log(`[Generated] ${Object.keys(ir.subscribables).length} Pinia Stores in ${storeDir}`);
|
|
154
|
+
|
|
155
|
+
console.log("[Compiler] Build Complete.");
|
|
156
|
+
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error(e);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
console.log("Unknown command. Try 'compile'.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ws.js';
|
package/src/client/ws.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Core WebSocket Manager for TZQL Client
|
|
2
|
+
// Handles connection, auth, reconnects, and message dispatching.
|
|
3
|
+
// This is a pure transport layer - it does not manage or cache data.
|
|
4
|
+
|
|
5
|
+
export interface WebSocketOptions {
|
|
6
|
+
url?: string;
|
|
7
|
+
maxReconnectAttempts?: number;
|
|
8
|
+
tokenName?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Get default token name from environment (build-time injection)
|
|
12
|
+
function getDefaultTokenName(): string {
|
|
13
|
+
// Vite: import.meta.env.VITE_TZQL_TOKEN_NAME
|
|
14
|
+
// @ts-ignore
|
|
15
|
+
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TZQL_TOKEN_NAME) {
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
return import.meta.env.VITE_TZQL_TOKEN_NAME;
|
|
18
|
+
}
|
|
19
|
+
// Node/bundlers: process.env.TZQL_TOKEN_NAME
|
|
20
|
+
if (typeof process !== 'undefined' && process.env?.TZQL_TOKEN_NAME) {
|
|
21
|
+
return process.env.TZQL_TOKEN_NAME;
|
|
22
|
+
}
|
|
23
|
+
return 'tzql_token';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class WebSocketManager {
|
|
27
|
+
protected ws: WebSocket | null = null;
|
|
28
|
+
protected messageId = 0;
|
|
29
|
+
protected pendingRequests = new Map<number, { resolve: (val: any) => void; reject: (err: any) => void }>();
|
|
30
|
+
protected methodHandlers = new Map<string, Set<(params: any) => void>>();
|
|
31
|
+
protected subscriptionCallbacks = new Map<string, (event: any) => void>();
|
|
32
|
+
protected readyCallbacks = new Set<(user: any) => void>();
|
|
33
|
+
protected reconnectAttempts = 0;
|
|
34
|
+
protected maxReconnectAttempts = 5;
|
|
35
|
+
protected tokenName = 'tzql_token';
|
|
36
|
+
protected isShuttingDown = false;
|
|
37
|
+
|
|
38
|
+
// Connection state
|
|
39
|
+
public user: any = null;
|
|
40
|
+
public ready: boolean = false;
|
|
41
|
+
|
|
42
|
+
// To be populated by generated code
|
|
43
|
+
public api: any = {};
|
|
44
|
+
|
|
45
|
+
constructor(options: WebSocketOptions = {}) {
|
|
46
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
47
|
+
this.tokenName = options.tokenName ?? getDefaultTokenName();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async login(credentials: any) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await this.call('login_user', credentials);
|
|
53
|
+
if (result && result.token) {
|
|
54
|
+
if (typeof localStorage !== 'undefined') {
|
|
55
|
+
localStorage.setItem(this.tokenName, result.token);
|
|
56
|
+
}
|
|
57
|
+
await this.authenticate(result.token);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async authenticate(token: string) {
|
|
66
|
+
return this.call('auth', { token });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async register(credentials: any, options: any = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const params = { ...credentials, options };
|
|
72
|
+
const result = await this.call('register_user', params);
|
|
73
|
+
if (result && result.token) {
|
|
74
|
+
if (typeof localStorage !== 'undefined') {
|
|
75
|
+
localStorage.setItem(this.tokenName, result.token);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async logout() {
|
|
85
|
+
if (typeof localStorage !== 'undefined') {
|
|
86
|
+
localStorage.removeItem(this.tokenName);
|
|
87
|
+
}
|
|
88
|
+
this.user = null;
|
|
89
|
+
this.ready = false;
|
|
90
|
+
try { await this.call('logout'); } catch(e) {}
|
|
91
|
+
this.ws?.close();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
connect(url: string | null = null, timeout = 5000): Promise<void> {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
this.ready = false;
|
|
97
|
+
this.user = null;
|
|
98
|
+
|
|
99
|
+
let wsUrl = url;
|
|
100
|
+
if (!wsUrl) {
|
|
101
|
+
if (typeof window !== "undefined") {
|
|
102
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
103
|
+
wsUrl = protocol + "//" + window.location.host + "/ws";
|
|
104
|
+
} else {
|
|
105
|
+
wsUrl = "ws://localhost:3000/ws";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof localStorage !== 'undefined') {
|
|
110
|
+
const token = localStorage.getItem(this.tokenName);
|
|
111
|
+
if (token) {
|
|
112
|
+
if (wsUrl.includes('?')) wsUrl += '&token=' + encodeURIComponent(token);
|
|
113
|
+
else wsUrl += '?token=' + encodeURIComponent(token);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const connectionTimeout = setTimeout(() => {
|
|
118
|
+
if (this.ws) this.ws.close();
|
|
119
|
+
reject(new Error('WebSocket connection timed out after ' + timeout + 'ms'));
|
|
120
|
+
}, timeout);
|
|
121
|
+
|
|
122
|
+
this.ws = new WebSocket(wsUrl);
|
|
123
|
+
|
|
124
|
+
this.ws.onopen = () => {
|
|
125
|
+
clearTimeout(connectionTimeout);
|
|
126
|
+
console.log('[TZQL] Connected to ' + wsUrl);
|
|
127
|
+
this.reconnectAttempts = 0;
|
|
128
|
+
resolve();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
this.ws.onmessage = (event) => {
|
|
132
|
+
try {
|
|
133
|
+
const message = JSON.parse(event.data);
|
|
134
|
+
this.handleMessage(message);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error("[TZQL] Failed to parse message:", error);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
this.ws.onclose = () => {
|
|
141
|
+
console.log("[TZQL] Disconnected");
|
|
142
|
+
if (!this.isShuttingDown) {
|
|
143
|
+
this.attemptReconnect();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
this.ws.onerror = (error) => {
|
|
148
|
+
clearTimeout(connectionTimeout);
|
|
149
|
+
console.error("[TZQL] Connection error:", error);
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
attemptReconnect() {
|
|
156
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
157
|
+
this.reconnectAttempts++;
|
|
158
|
+
const delay = 1000 * this.reconnectAttempts;
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
console.log('[TZQL] Reconnecting (' + this.reconnectAttempts + ')...');
|
|
161
|
+
this.connect();
|
|
162
|
+
}, delay);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
handleMessage(message: any) {
|
|
167
|
+
// Handle connection:ready message
|
|
168
|
+
if (message.method === "connection:ready") {
|
|
169
|
+
this.user = message.params?.user || null;
|
|
170
|
+
this.ready = true;
|
|
171
|
+
this.readyCallbacks.forEach((cb) => cb(this.user));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle RPC responses (messages with id)
|
|
176
|
+
if (message.id && this.pendingRequests.has(message.id)) {
|
|
177
|
+
const resolver = this.pendingRequests.get(message.id);
|
|
178
|
+
if (resolver) {
|
|
179
|
+
this.pendingRequests.delete(message.id);
|
|
180
|
+
if (message.error) {
|
|
181
|
+
const err: any = new Error(message.error.message || 'Unknown error');
|
|
182
|
+
err.code = message.error.code;
|
|
183
|
+
resolver.reject(err);
|
|
184
|
+
} else {
|
|
185
|
+
resolver.resolve(message.result);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Handle subscription events - dispatch to registered subscription callbacks
|
|
192
|
+
if (message.method === "subscription:event") {
|
|
193
|
+
const event = message.params?.event;
|
|
194
|
+
if (event) {
|
|
195
|
+
// Dispatch to all subscription handlers - they filter by table/scope
|
|
196
|
+
for (const [subId, callback] of this.subscriptionCallbacks) {
|
|
197
|
+
callback(event);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle other server-initiated messages (broadcasts) - route to registered handlers
|
|
204
|
+
if (message.method) {
|
|
205
|
+
const handlers = this.methodHandlers.get(message.method);
|
|
206
|
+
if (handlers) {
|
|
207
|
+
handlers.forEach((cb) => cb(message.params));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
call(method: string, params: any = {}) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
215
|
+
reject(new Error("WebSocket not connected"));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const id = ++this.messageId;
|
|
219
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
220
|
+
this.ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id }));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Register a callback for a server-initiated method
|
|
226
|
+
* @param method - The method name to listen for
|
|
227
|
+
* @param callback - Called with params when server sends this method
|
|
228
|
+
* @returns Unsubscribe function
|
|
229
|
+
*/
|
|
230
|
+
on(method: string, callback: (params: any) => void) {
|
|
231
|
+
if (!this.methodHandlers.has(method)) {
|
|
232
|
+
this.methodHandlers.set(method, new Set());
|
|
233
|
+
}
|
|
234
|
+
this.methodHandlers.get(method)!.add(callback);
|
|
235
|
+
return () => {
|
|
236
|
+
const handlers = this.methodHandlers.get(method);
|
|
237
|
+
if (handlers) {
|
|
238
|
+
handlers.delete(callback);
|
|
239
|
+
if (handlers.size === 0) {
|
|
240
|
+
this.methodHandlers.delete(method);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Register a callback to be called when connection is ready
|
|
248
|
+
* @param callback - Called with user profile (or null if not authenticated)
|
|
249
|
+
* @returns Unsubscribe function
|
|
250
|
+
*/
|
|
251
|
+
onReady(callback: (user: any) => void) {
|
|
252
|
+
if (this.ready) {
|
|
253
|
+
callback(this.user);
|
|
254
|
+
}
|
|
255
|
+
this.readyCallbacks.add(callback);
|
|
256
|
+
return () => this.readyCallbacks.delete(callback);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Subscribe to a subscribable document
|
|
261
|
+
* @param method - The subscribe method name (e.g., "subscribe_venue_detail")
|
|
262
|
+
* @param params - Subscription parameters
|
|
263
|
+
* @param callback - Called with initial data and on updates
|
|
264
|
+
* @returns Promise that resolves to an unsubscribe function
|
|
265
|
+
*/
|
|
266
|
+
async subscribe(method: string, params: any, callback: (data: any) => void): Promise<() => void> {
|
|
267
|
+
// Call server to get initial snapshot and subscription_id
|
|
268
|
+
const result = await this.call(method, params) as {
|
|
269
|
+
subscription_id: string;
|
|
270
|
+
data: any;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Register callback for subscription events
|
|
274
|
+
this.subscriptionCallbacks.set(result.subscription_id, callback);
|
|
275
|
+
|
|
276
|
+
// Call callback with initial data
|
|
277
|
+
callback(result.data);
|
|
278
|
+
|
|
279
|
+
// Return unsubscribe function
|
|
280
|
+
return () => {
|
|
281
|
+
this.subscriptionCallbacks.delete(result.subscription_id);
|
|
282
|
+
// Notify server
|
|
283
|
+
this.call(`unsubscribe_${method.replace('subscribe_', '')}`, { subscription_id: result.subscription_id }).catch(() => {});
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jwtVerify, SignJWT } from "jose";
|
|
2
|
+
|
|
3
|
+
const JWT_SECRET = process.env.JWT_SECRET || "default_dev_secret";
|
|
4
|
+
const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
|
|
5
|
+
|
|
6
|
+
export interface UserSession {
|
|
7
|
+
userId: number;
|
|
8
|
+
role?: string;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function verifyToken(token: string): Promise<UserSession> {
|
|
13
|
+
try {
|
|
14
|
+
const { payload } = await jwtVerify(token, SECRET_KEY);
|
|
15
|
+
|
|
16
|
+
// Normalize user_id vs sub
|
|
17
|
+
const uid = payload.user_id || payload.sub;
|
|
18
|
+
|
|
19
|
+
if (!uid) {
|
|
20
|
+
throw new Error("Invalid token: missing user_id");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
userId: Number(uid),
|
|
25
|
+
role: payload.role as string,
|
|
26
|
+
...payload
|
|
27
|
+
};
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
throw new Error(`Authentication failed: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function signToken(payload: any): Promise<string> {
|
|
34
|
+
return await new SignJWT(payload)
|
|
35
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
36
|
+
.setIssuedAt()
|
|
37
|
+
.setExpirationTime('7d')
|
|
38
|
+
.sign(SECRET_KEY);
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
export class Database {
|
|
4
|
+
sql: postgres.Sql;
|
|
5
|
+
|
|
6
|
+
constructor(connectionString: string, options: any = {}) {
|
|
7
|
+
this.sql = postgres(connectionString, {
|
|
8
|
+
max: options.max || 10,
|
|
9
|
+
idle_timeout: options.idleTimeout || 20,
|
|
10
|
+
connect_timeout: options.connectTimeout || 10,
|
|
11
|
+
onnotice: () => {}, // Suppress notices
|
|
12
|
+
...options
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async query(text: string, params: any[] = []) {
|
|
17
|
+
return this.sql.unsafe(text, params);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async listen(channel: string, callback: (payload: string) => void) {
|
|
21
|
+
console.log(`[DB] Setting up LISTEN on channel: ${channel}`);
|
|
22
|
+
const result = await this.sql.listen(channel, (payload) => {
|
|
23
|
+
console.log(`[DB] LISTEN callback triggered with payload:`, payload);
|
|
24
|
+
callback(payload);
|
|
25
|
+
});
|
|
26
|
+
console.log(`[DB] LISTEN setup complete, result:`, result);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async close() {
|
|
31
|
+
await this.sql.end();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export enum ErrorCode {
|
|
2
|
+
PERMISSION_DENIED = "PERMISSION_DENIED",
|
|
3
|
+
NOT_FOUND = "NOT_FOUND",
|
|
4
|
+
VALIDATION_ERROR = "VALIDATION_ERROR",
|
|
5
|
+
CONFLICT = "CONFLICT",
|
|
6
|
+
RATE_LIMITED = "RATE_LIMITED",
|
|
7
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class AppError extends Error {
|
|
11
|
+
code: ErrorCode;
|
|
12
|
+
constructor(code: ErrorCode, message?: string) {
|
|
13
|
+
super(message || code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function mapDatabaseError(err: any): AppError {
|
|
19
|
+
// If it's already an AppError (e.g. thrown explicitly from PL/pgSQL RAISE), pass it through
|
|
20
|
+
// Postgres RAISE EXCEPTION '...' usually comes as a generic error with a message
|
|
21
|
+
|
|
22
|
+
const code = err.code; // SQLSTATE
|
|
23
|
+
const message = err.message || "";
|
|
24
|
+
|
|
25
|
+
// 1. Explicit RAISE EXCEPTION from our PL/pgSQL functions
|
|
26
|
+
if (message.includes("permission_denied")) {
|
|
27
|
+
return new AppError(ErrorCode.PERMISSION_DENIED);
|
|
28
|
+
}
|
|
29
|
+
if (message.includes("not_found")) {
|
|
30
|
+
return new AppError(ErrorCode.NOT_FOUND);
|
|
31
|
+
}
|
|
32
|
+
if (message.includes("validation_error")) {
|
|
33
|
+
return new AppError(ErrorCode.VALIDATION_ERROR, message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Standard SQLSTATE Mapping
|
|
37
|
+
switch (code) {
|
|
38
|
+
case "23505": // Unique violation
|
|
39
|
+
return new AppError(ErrorCode.CONFLICT, "Unique constraint violation");
|
|
40
|
+
case "23503": // Foreign key violation
|
|
41
|
+
return new AppError(ErrorCode.VALIDATION_ERROR, "Invalid reference");
|
|
42
|
+
case "23502": // Not null violation
|
|
43
|
+
return new AppError(ErrorCode.VALIDATION_ERROR, "Missing required field");
|
|
44
|
+
case "42P01": // Undefined table (shouldn't happen in prod if manifest is correct)
|
|
45
|
+
return new AppError(ErrorCode.INTERNAL_ERROR, "Table not found");
|
|
46
|
+
default:
|
|
47
|
+
// Log the real error internally, return generic to client
|
|
48
|
+
// console.error(err); // Done in server.ts
|
|
49
|
+
return new AppError(ErrorCode.INTERNAL_ERROR);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { serve } from "bun";
|
|
2
|
+
import { config } from "dotenv";
|
|
3
|
+
import { Database } from "./db.js";
|
|
4
|
+
import { WebSocketServer } from "./ws.js";
|
|
5
|
+
import { loadManifest } from "./manifest_loader.js";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
|
|
9
|
+
// Re-export JS function registration API for custom functions
|
|
10
|
+
export { registerJsFunction, type JsFunctionHandler, type JsFunctionContext } from "./js_functions.js";
|
|
11
|
+
|
|
12
|
+
// Load .env file if present
|
|
13
|
+
config();
|
|
14
|
+
|
|
15
|
+
// Configuration
|
|
16
|
+
const PORT = process.env.PORT || 3000;
|
|
17
|
+
const DB_URL = process.env.DATABASE_URL || "postgres://dzql_test:dzql_test@localhost:5433/dzql_test";
|
|
18
|
+
const MANIFEST_PATH = process.env.MANIFEST_PATH || "./dist/runtime/manifest.json";
|
|
19
|
+
|
|
20
|
+
// 1. Initialize DB
|
|
21
|
+
const db = new Database(DB_URL);
|
|
22
|
+
|
|
23
|
+
// 2. Load Manifest
|
|
24
|
+
try {
|
|
25
|
+
const manifestPath = resolve(process.cwd(), MANIFEST_PATH);
|
|
26
|
+
const manifestContent = readFileSync(manifestPath, "utf-8");
|
|
27
|
+
const manifest = JSON.parse(manifestContent);
|
|
28
|
+
loadManifest(manifest);
|
|
29
|
+
console.log(`[Runtime] Loaded Manifest v${manifest.version}`);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.warn(`[Runtime] Warning: Could not load manifest from ${MANIFEST_PATH}. Ensure you have compiled the project.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 3. Initialize WebSocket Server
|
|
35
|
+
const wsServer = new WebSocketServer(db);
|
|
36
|
+
|
|
37
|
+
// 4. Start Commit Listener (Realtime)
|
|
38
|
+
async function startListener() {
|
|
39
|
+
console.log("[Runtime] Setting up LISTEN on dzql_v2 channel...");
|
|
40
|
+
await db.listen("dzql_v2", async (payload) => {
|
|
41
|
+
console.log(`[Runtime] RAW NOTIFY received:`, payload);
|
|
42
|
+
try {
|
|
43
|
+
const { commit_id } = JSON.parse(payload);
|
|
44
|
+
console.log(`[Runtime] Received Commit: ${commit_id}`);
|
|
45
|
+
|
|
46
|
+
// Fetch events
|
|
47
|
+
const events = await db.query(`
|
|
48
|
+
SELECT * FROM dzql_v2.events
|
|
49
|
+
WHERE commit_id = $1
|
|
50
|
+
ORDER BY id ASC
|
|
51
|
+
`, [commit_id]);
|
|
52
|
+
|
|
53
|
+
for (const event of events) {
|
|
54
|
+
// Broadcast
|
|
55
|
+
const message = JSON.stringify({
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
method: "subscription:event",
|
|
58
|
+
params: {
|
|
59
|
+
event: {
|
|
60
|
+
table: event.table_name,
|
|
61
|
+
op: event.op,
|
|
62
|
+
pk: event.pk,
|
|
63
|
+
data: event.data,
|
|
64
|
+
// old_data is filtered out
|
|
65
|
+
user_id: event.user_id
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
wsServer.broadcast(message);
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error("[Runtime] Listener Error:", e);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
console.log("[Runtime] Listening for DB Events...");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wait for listener to be ready before starting server
|
|
79
|
+
await startListener();
|
|
80
|
+
|
|
81
|
+
// 5. Start Web Server
|
|
82
|
+
const server = serve({
|
|
83
|
+
port: PORT,
|
|
84
|
+
async fetch(req, server) {
|
|
85
|
+
const url = new URL(req.url);
|
|
86
|
+
|
|
87
|
+
// Extract token from query params for WebSocket connections
|
|
88
|
+
const token = url.searchParams.get("token");
|
|
89
|
+
|
|
90
|
+
if (server.upgrade(req, { data: { token } })) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
return new Response("TZQL Runtime Active", { status: 200 });
|
|
94
|
+
},
|
|
95
|
+
websocket: wsServer.handlers
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
console.log(`[Runtime] Server listening on port ${PORT}`);
|