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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ServerWebSocket } from "bun";
|
|
2
|
+
import { handleRequest } from "./server.js"; // The secure router
|
|
3
|
+
import { verifyToken, signToken } from "./auth.js";
|
|
4
|
+
import { Database } from "./db.js";
|
|
5
|
+
|
|
6
|
+
interface WSContext {
|
|
7
|
+
id: string;
|
|
8
|
+
userId?: number;
|
|
9
|
+
subscriptions: Set<string>; // Set of subscription IDs
|
|
10
|
+
lastPing: number;
|
|
11
|
+
token?: string; // Token passed from URL query params during upgrade
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class WebSocketServer {
|
|
15
|
+
private connections = new Map<string, ServerWebSocket<WSContext>>();
|
|
16
|
+
private db: Database;
|
|
17
|
+
|
|
18
|
+
constructor(db: Database) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
// Start heartbeat interval
|
|
21
|
+
setInterval(() => this.heartbeat(), 30000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Bun.serve websocket handler hooks
|
|
25
|
+
get handlers() {
|
|
26
|
+
return {
|
|
27
|
+
open: (ws: ServerWebSocket<WSContext>) => this.handleOpen(ws),
|
|
28
|
+
message: (ws: ServerWebSocket<WSContext>, message: string) => this.handleMessage(ws, message),
|
|
29
|
+
close: (ws: ServerWebSocket<WSContext>) => this.handleClose(ws),
|
|
30
|
+
drain: (ws: ServerWebSocket<WSContext>) => {}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async handleOpen(ws: ServerWebSocket<WSContext>) {
|
|
35
|
+
const id = Math.random().toString(36).slice(2);
|
|
36
|
+
const token = ws.data?.token;
|
|
37
|
+
|
|
38
|
+
ws.data = {
|
|
39
|
+
id,
|
|
40
|
+
subscriptions: new Set(),
|
|
41
|
+
lastPing: Date.now()
|
|
42
|
+
};
|
|
43
|
+
this.connections.set(id, ws);
|
|
44
|
+
console.log(`[WS] Client ${id} connected`);
|
|
45
|
+
|
|
46
|
+
// Subscribe to global broadcast channel initially
|
|
47
|
+
ws.subscribe("broadcast");
|
|
48
|
+
|
|
49
|
+
// If token was provided in URL, verify and authenticate
|
|
50
|
+
let user: any = null;
|
|
51
|
+
if (token) {
|
|
52
|
+
try {
|
|
53
|
+
const session = await verifyToken(token);
|
|
54
|
+
ws.data.userId = session.userId;
|
|
55
|
+
console.log(`[WS] Client ${id} authenticated via token as user ${session.userId}`);
|
|
56
|
+
|
|
57
|
+
// Fetch user profile using get_users
|
|
58
|
+
try {
|
|
59
|
+
const profile = await handleRequest(this.db, 'get_users', { id: session.userId }, session.userId);
|
|
60
|
+
if (profile) {
|
|
61
|
+
// Remove sensitive data
|
|
62
|
+
const { password_hash, ...safeProfile } = profile;
|
|
63
|
+
user = safeProfile;
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error(`[WS] Failed to fetch profile for user ${session.userId}:`, e);
|
|
67
|
+
// Still authenticated, just no profile
|
|
68
|
+
user = { id: session.userId };
|
|
69
|
+
}
|
|
70
|
+
} catch (e: any) {
|
|
71
|
+
console.log(`[WS] Client ${id} token verification failed:`, e.message);
|
|
72
|
+
// Token invalid, user remains null (anonymous connection)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Send connection:ready message
|
|
77
|
+
ws.send(JSON.stringify({
|
|
78
|
+
jsonrpc: "2.0",
|
|
79
|
+
method: "connection:ready",
|
|
80
|
+
params: { user }
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async handleMessage(ws: ServerWebSocket<WSContext>, message: string) {
|
|
85
|
+
ws.data.lastPing = Date.now(); // Update activity
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const req = JSON.parse(message);
|
|
89
|
+
|
|
90
|
+
// Handle Ping (Client-side heartbeat)
|
|
91
|
+
if (req.method === 'ping') {
|
|
92
|
+
ws.send(JSON.stringify({ id: req.id, result: 'pong' }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Handle Auth Handshake
|
|
97
|
+
if (req.method === 'auth') {
|
|
98
|
+
try {
|
|
99
|
+
const session = await verifyToken(req.params.token);
|
|
100
|
+
ws.data.userId = session.userId;
|
|
101
|
+
ws.send(JSON.stringify({ id: req.id, result: { success: true, userId: session.userId } }));
|
|
102
|
+
console.log(`[WS] Client ${ws.data.id} authenticated as user ${session.userId}`);
|
|
103
|
+
} catch (e: any) {
|
|
104
|
+
ws.send(JSON.stringify({ id: req.id, error: { code: 'UNAUTHORIZED', message: e.message } }));
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Require Auth for other methods?
|
|
110
|
+
// V2 spec says `p_user_id` is passed to functions.
|
|
111
|
+
// If not auth'd, we can pass null or throw.
|
|
112
|
+
const userId = ws.data.userId || null;
|
|
113
|
+
|
|
114
|
+
// Handle RPC
|
|
115
|
+
if (req.method) {
|
|
116
|
+
if (req.method.startsWith("subscribe_")) {
|
|
117
|
+
// Handle Subscription Registration
|
|
118
|
+
const subscribableName = req.method.replace("subscribe_", "");
|
|
119
|
+
const getFnName = `get_${subscribableName}`;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Call the get_ function to fetch initial data
|
|
123
|
+
const snapshot = await handleRequest(this.db, getFnName, req.params, userId);
|
|
124
|
+
|
|
125
|
+
// Register subscription
|
|
126
|
+
const subId = `${subscribableName}:${JSON.stringify(req.params)}`;
|
|
127
|
+
ws.data.subscriptions.add(subId);
|
|
128
|
+
|
|
129
|
+
// Return snapshot with subscription_id and schema
|
|
130
|
+
ws.send(JSON.stringify({
|
|
131
|
+
id: req.id,
|
|
132
|
+
result: {
|
|
133
|
+
subscription_id: subId,
|
|
134
|
+
data: snapshot.data,
|
|
135
|
+
schema: snapshot.schema
|
|
136
|
+
}
|
|
137
|
+
}));
|
|
138
|
+
} catch (e: any) {
|
|
139
|
+
ws.send(JSON.stringify({
|
|
140
|
+
id: req.id,
|
|
141
|
+
error: { code: e.code || 'INTERNAL_ERROR', message: e.message }
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// Normal Function Call
|
|
146
|
+
const result = await handleRequest(this.db, req.method, req.params, userId);
|
|
147
|
+
|
|
148
|
+
// Auto-generate token for auth methods
|
|
149
|
+
if (req.method === 'login_user' || req.method === 'register_user') {
|
|
150
|
+
const token = await signToken({ user_id: result.user_id, role: 'user' });
|
|
151
|
+
// Return profile + token
|
|
152
|
+
ws.send(JSON.stringify({ id: req.id, result: { ...result, token } }));
|
|
153
|
+
} else {
|
|
154
|
+
ws.send(JSON.stringify({ id: req.id, result }));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
} catch (e: any) {
|
|
160
|
+
console.error(`[WS] Error processing message from ${ws.data.id}:`, e);
|
|
161
|
+
// Try to send error back if JSON parse didn't fail
|
|
162
|
+
try {
|
|
163
|
+
const reqId = JSON.parse(message).id;
|
|
164
|
+
ws.send(JSON.stringify({ id: reqId, error: { code: 'INTERNAL_ERROR', message: e.message } }));
|
|
165
|
+
} catch (ignore) {}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private handleClose(ws: ServerWebSocket<WSContext>) {
|
|
170
|
+
this.connections.delete(ws.data.id);
|
|
171
|
+
console.log(`[WS] Client ${ws.data.id} disconnected`);
|
|
172
|
+
}
|
|
173
|
+
|
|
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
|
+
public broadcast(message: string) {
|
|
186
|
+
// Use Bun's native publish for efficiency
|
|
187
|
+
// 'broadcast' topic is subscribed by all on connect
|
|
188
|
+
// In V2, we might have specific topics per subscription key
|
|
189
|
+
// server.publish("broadcast", message);
|
|
190
|
+
// Since this class doesn't hold the 'server' instance directly,
|
|
191
|
+
// we iterate or need to pass server in.
|
|
192
|
+
// For now, iteration is fine for prototype.
|
|
193
|
+
for (const ws of this.connections.values()) {
|
|
194
|
+
ws.send(message);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/shared/ir.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// INPUT TYPES - Used for domain configuration
|
|
3
|
+
// ============================================
|
|
4
|
+
|
|
5
|
+
/** Action types supported in graph rules */
|
|
6
|
+
export type GraphRuleActionType = 'create' | 'update' | 'delete' | 'validate' | 'execute' | 'reactor';
|
|
7
|
+
|
|
8
|
+
/** Configuration for a graph rule action */
|
|
9
|
+
export interface GraphRuleActionConfig {
|
|
10
|
+
type: GraphRuleActionType;
|
|
11
|
+
entity?: string; // Target entity for create action
|
|
12
|
+
target?: string; // Target entity for update/delete actions
|
|
13
|
+
name?: string; // Reactor name for reactor type
|
|
14
|
+
function?: string; // Function name for validate/execute
|
|
15
|
+
data?: Record<string, string>; // Data for create/update (field -> @variable)
|
|
16
|
+
match?: Record<string, string>; // Match condition for update/delete
|
|
17
|
+
params?: Record<string, string>; // Parameters for reactor/validate/execute
|
|
18
|
+
error_message?: string; // Error message for validate
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Configuration for a named graph rule */
|
|
22
|
+
export interface GraphRuleConfig {
|
|
23
|
+
description?: string;
|
|
24
|
+
condition?: string; // e.g., "@before.status = 'draft' AND @after.status = 'posted'"
|
|
25
|
+
actions: GraphRuleActionConfig[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Include configuration - either a simple string (entity name) or full object */
|
|
29
|
+
export interface IncludeConfig {
|
|
30
|
+
entity: string;
|
|
31
|
+
filter?: Record<string, string>;
|
|
32
|
+
includes?: Record<string, string | IncludeConfig>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Many-to-many relationship configuration */
|
|
36
|
+
export interface ManyToManyConfig {
|
|
37
|
+
junctionTable: string;
|
|
38
|
+
localKey: string;
|
|
39
|
+
foreignKey: string;
|
|
40
|
+
targetEntity: string;
|
|
41
|
+
idField: string;
|
|
42
|
+
expand?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Permission paths configuration */
|
|
46
|
+
export interface PermissionPathsConfig {
|
|
47
|
+
view?: string[];
|
|
48
|
+
create?: string[];
|
|
49
|
+
update?: string[];
|
|
50
|
+
delete?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Temporal fields configuration */
|
|
54
|
+
export interface TemporalConfig {
|
|
55
|
+
validFrom: string;
|
|
56
|
+
validTo: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Graph rules grouped by trigger */
|
|
60
|
+
export interface GraphRulesConfig {
|
|
61
|
+
on_create?: Record<string, GraphRuleConfig>;
|
|
62
|
+
on_update?: Record<string, GraphRuleConfig>;
|
|
63
|
+
on_delete?: Record<string, GraphRuleConfig>;
|
|
64
|
+
primary_key?: string[]; // Composite primary key fields
|
|
65
|
+
many_to_many?: Record<string, ManyToManyConfig>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Entity configuration as provided in domain file */
|
|
69
|
+
export interface EntityConfig {
|
|
70
|
+
schema: Record<string, string>;
|
|
71
|
+
label?: string;
|
|
72
|
+
searchable?: string[];
|
|
73
|
+
hidden?: string[];
|
|
74
|
+
primaryKey?: string[];
|
|
75
|
+
managed?: boolean;
|
|
76
|
+
softDelete?: boolean;
|
|
77
|
+
fieldDefaults?: Record<string, string>;
|
|
78
|
+
permissions?: PermissionPathsConfig;
|
|
79
|
+
includes?: Record<string, string | IncludeConfig>;
|
|
80
|
+
manyToMany?: Record<string, ManyToManyConfig>;
|
|
81
|
+
graphRules?: GraphRulesConfig;
|
|
82
|
+
temporal?: TemporalConfig;
|
|
83
|
+
constraints?: string[];
|
|
84
|
+
indexes?: string[];
|
|
85
|
+
notifications?: Record<string, string[]>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Subscribable configuration as provided in domain file */
|
|
89
|
+
export interface SubscribableConfig {
|
|
90
|
+
params: Record<string, string>;
|
|
91
|
+
root: { entity: string; key: string };
|
|
92
|
+
includes?: Record<string, string | IncludeConfig>;
|
|
93
|
+
scopeTables?: string[];
|
|
94
|
+
canSubscribe?: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Custom function configuration */
|
|
98
|
+
export interface CustomFunctionConfig {
|
|
99
|
+
name: string;
|
|
100
|
+
sql: string;
|
|
101
|
+
args?: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Complete domain configuration as provided in domain file */
|
|
105
|
+
export interface DomainConfig {
|
|
106
|
+
entities: Record<string, EntityConfig>;
|
|
107
|
+
subscribables?: Record<string, SubscribableConfig>;
|
|
108
|
+
customFunctions?: CustomFunctionConfig[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================
|
|
112
|
+
// IR TYPES - Intermediate representation
|
|
113
|
+
// ============================================
|
|
114
|
+
|
|
115
|
+
export interface EntityIR {
|
|
116
|
+
name: string;
|
|
117
|
+
table: string;
|
|
118
|
+
primaryKey: string[];
|
|
119
|
+
columns: Array<{ name: string; type: string; isArray: boolean }>;
|
|
120
|
+
labelField?: string;
|
|
121
|
+
softDelete?: boolean;
|
|
122
|
+
managed?: boolean; // If false, skip CRUD function generation (for junction tables)
|
|
123
|
+
hidden?: string[]; // Fields to exclude from query results (e.g., password_hash)
|
|
124
|
+
fieldDefaults?: Record<string, string>;
|
|
125
|
+
permissions: {
|
|
126
|
+
view: string[];
|
|
127
|
+
create: string[];
|
|
128
|
+
update: string[];
|
|
129
|
+
delete: string[];
|
|
130
|
+
};
|
|
131
|
+
relationships: Record<string, RelationshipIR>;
|
|
132
|
+
manyToMany: Record<string, ManyToManyIR>;
|
|
133
|
+
graphRules: {
|
|
134
|
+
onCreate: GraphRuleIR[];
|
|
135
|
+
onUpdate: GraphRuleIR[];
|
|
136
|
+
onDelete: GraphRuleIR[];
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ManyToManyIR {
|
|
141
|
+
junctionTable: string;
|
|
142
|
+
localKey: string;
|
|
143
|
+
foreignKey: string;
|
|
144
|
+
targetEntity: string;
|
|
145
|
+
idField: string;
|
|
146
|
+
expand: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface RelationshipIR {
|
|
150
|
+
type: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
|
151
|
+
targetEntity: string;
|
|
152
|
+
localKey: string;
|
|
153
|
+
foreignKey: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface GraphRuleIR {
|
|
157
|
+
trigger: 'create' | 'update' | 'delete';
|
|
158
|
+
action: 'create' | 'update' | 'delete' | 'reactor' | 'validate' | 'execute';
|
|
159
|
+
target: string; // entity name, reactor name, or function name
|
|
160
|
+
ruleName?: string; // The name of the rule (for comments)
|
|
161
|
+
description?: string; // Human-readable description
|
|
162
|
+
condition?: string; // e.g., "@before.status = 'draft' AND @after.status = 'posted'"
|
|
163
|
+
params: Record<string, string>; // Data for create, or params for reactor/validate/execute
|
|
164
|
+
match?: Record<string, string>; // WHERE clause for update/delete actions
|
|
165
|
+
error_message?: string; // Error message for validate action
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SubscribableIR {
|
|
169
|
+
name: string;
|
|
170
|
+
params: Record<string, string>;
|
|
171
|
+
root: {
|
|
172
|
+
entity: string;
|
|
173
|
+
key: string;
|
|
174
|
+
};
|
|
175
|
+
includes: Record<string, IncludeIR>;
|
|
176
|
+
scopeTables: string[];
|
|
177
|
+
canSubscribe: string[]; // Permission paths
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface IncludeIR {
|
|
181
|
+
relation: string; // The key (e.g., 'sites')
|
|
182
|
+
entity: string; // The target table (e.g., 'sites')
|
|
183
|
+
filter?: Record<string, string>;
|
|
184
|
+
includes?: Record<string, IncludeIR>; // Nested includes
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface CustomFunctionIR {
|
|
188
|
+
name: string;
|
|
189
|
+
sql: string;
|
|
190
|
+
args?: string[]; // For manifest allowlist
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface DomainIR {
|
|
194
|
+
entities: Record<string, EntityIR>;
|
|
195
|
+
subscribables: Record<string, SubscribableIR>;
|
|
196
|
+
customFunctions: CustomFunctionIR[];
|
|
197
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generateClientSDK } from "../src/cli/codegen/client.js";
|
|
3
|
+
import { generateManifest } from "../src/cli/codegen/manifest.js";
|
|
4
|
+
import { generateIR } from "../src/cli/compiler/ir.js";
|
|
5
|
+
|
|
6
|
+
const mockManifest = generateManifest(generateIR({
|
|
7
|
+
entities: {
|
|
8
|
+
posts: {
|
|
9
|
+
schema: { id: "serial primary key", title: "text" },
|
|
10
|
+
permissions: { create: [], view: [] }
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
subscribables: {}
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("Client SDK Generation", () => {
|
|
17
|
+
test("should generate a TypeScript SDK with typed API", () => {
|
|
18
|
+
const tsCode = generateClientSDK(mockManifest);
|
|
19
|
+
|
|
20
|
+
// Check imports
|
|
21
|
+
expect(tsCode).toContain("import { WebSocketManager } from 'tzql/client'");
|
|
22
|
+
|
|
23
|
+
// Check interface definition
|
|
24
|
+
expect(tsCode).toContain("export interface TzqlAPI {");
|
|
25
|
+
expect(tsCode).toContain("save_posts: (params: SavePostsParams) => Promise<Posts>");
|
|
26
|
+
expect(tsCode).toContain("get_posts: (params: PostsPK) => Promise<Posts | null>");
|
|
27
|
+
|
|
28
|
+
// Check class definition
|
|
29
|
+
expect(tsCode).toContain("export class GeneratedWebSocketManager extends WebSocketManager");
|
|
30
|
+
expect(tsCode).toContain("api: TzqlAPI");
|
|
31
|
+
|
|
32
|
+
// Check API implementation
|
|
33
|
+
expect(tsCode).toContain("this.call('save_posts', params)");
|
|
34
|
+
|
|
35
|
+
// Check singleton export
|
|
36
|
+
expect(tsCode).toContain("export const ws = new GeneratedWebSocketManager()");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generateCoreSQL, generateEntitySQL } from "../src/cli/codegen/sql.js";
|
|
3
|
+
import { generateManifest } from "../src/cli/codegen/manifest.js";
|
|
4
|
+
import { generateIR } from "../src/cli/compiler/ir.js";
|
|
5
|
+
|
|
6
|
+
const mockEntityIR = {
|
|
7
|
+
name: "posts",
|
|
8
|
+
table: "posts",
|
|
9
|
+
primaryKey: ["id"],
|
|
10
|
+
columns: [
|
|
11
|
+
{ name: "id", type: "serial PRIMARY KEY" },
|
|
12
|
+
{ name: "title", type: "text NOT NULL" }
|
|
13
|
+
],
|
|
14
|
+
permissions: {
|
|
15
|
+
create: [],
|
|
16
|
+
view: []
|
|
17
|
+
},
|
|
18
|
+
graphRules: {
|
|
19
|
+
onCreate: []
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("SQL Code Generation", () => {
|
|
24
|
+
test("generateCoreSQL should produce migration table", () => {
|
|
25
|
+
const sql = generateCoreSQL();
|
|
26
|
+
expect(sql).toContain("CREATE SCHEMA IF NOT EXISTS dzql_v2");
|
|
27
|
+
expect(sql).toContain("CREATE TABLE IF NOT EXISTS dzql_v2.migrations");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("generateEntitySQL should produce save function", () => {
|
|
31
|
+
const sql = generateEntitySQL("posts", mockEntityIR);
|
|
32
|
+
expect(sql).toContain("CREATE OR REPLACE FUNCTION dzql_v2.save_posts");
|
|
33
|
+
expect(sql).toContain("AND EXISTS(SELECT 1 FROM posts WHERE"); // Check existence check (composite PK support)
|
|
34
|
+
expect(sql).toContain("UPDATE posts SET"); // Check update branch
|
|
35
|
+
expect(sql).toContain("INSERT INTO posts"); // Check insert branch
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const mockRawEntity = {
|
|
40
|
+
schema: {
|
|
41
|
+
id: "serial PRIMARY KEY",
|
|
42
|
+
title: "text NOT NULL"
|
|
43
|
+
},
|
|
44
|
+
permissions: {
|
|
45
|
+
create: [],
|
|
46
|
+
view: []
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("Manifest Generation", () => {
|
|
51
|
+
test("should generate allowlist", () => {
|
|
52
|
+
// Create IR first
|
|
53
|
+
const ir = generateIR({
|
|
54
|
+
entities: { posts: mockRawEntity },
|
|
55
|
+
subscribables: {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const manifest = generateManifest(ir);
|
|
59
|
+
|
|
60
|
+
expect(manifest.version).toBe("2.0.0");
|
|
61
|
+
expect(manifest.functions).toBeDefined();
|
|
62
|
+
|
|
63
|
+
// Check allowlist
|
|
64
|
+
expect(manifest.functions["save_posts"]).toBeDefined();
|
|
65
|
+
expect(manifest.functions["get_posts"]).toBeDefined();
|
|
66
|
+
expect(manifest.functions["delete_posts"]).toBeDefined();
|
|
67
|
+
|
|
68
|
+
// Check signatures (basic check for now)
|
|
69
|
+
expect(manifest.functions["save_posts"].args).toEqual(["p_user_id", "p_data"]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { analyzeDomain } from "../src/cli/compiler/analyzer.js";
|
|
3
|
+
|
|
4
|
+
// Mock domain for testing
|
|
5
|
+
const validDomain = {
|
|
6
|
+
entities: {
|
|
7
|
+
users: { schema: { id: "serial primary key" } },
|
|
8
|
+
posts: { schema: { id: "serial primary key", user_id: "int" } }
|
|
9
|
+
},
|
|
10
|
+
subscribables: {
|
|
11
|
+
user_posts: {
|
|
12
|
+
root: { entity: "users" },
|
|
13
|
+
includes: {
|
|
14
|
+
posts: { entity: "posts" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const invalidDomain = {
|
|
21
|
+
entities: {
|
|
22
|
+
users: { schema: { id: "serial primary key" } }
|
|
23
|
+
},
|
|
24
|
+
subscribables: {
|
|
25
|
+
broken_feed: {
|
|
26
|
+
root: { entity: "users" },
|
|
27
|
+
includes: {
|
|
28
|
+
posts: { entity: "missing_posts" } // <--- Reference to missing entity
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("Compiler Analyzer", () => {
|
|
35
|
+
test("should pass for valid domain", () => {
|
|
36
|
+
const errors = analyzeDomain(validDomain);
|
|
37
|
+
expect(errors).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should fail for missing entity reference", () => {
|
|
41
|
+
const errors = analyzeDomain(invalidDomain);
|
|
42
|
+
expect(errors).toHaveLength(1);
|
|
43
|
+
expect(errors[0]).toContain("references unknown entity 'missing_posts'");
|
|
44
|
+
});
|
|
45
|
+
});
|