dzql 0.1.0-alpha.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/package.json +65 -0
- package/src/client/ui-configs/sample-2.js +207 -0
- package/src/client/ui-loader.js +618 -0
- package/src/client/ui.js +990 -0
- package/src/client/ws.js +352 -0
- package/src/client.js +9 -0
- package/src/database/migrations/001_schema.sql +59 -0
- package/src/database/migrations/002_functions.sql +742 -0
- package/src/database/migrations/003_operations.sql +725 -0
- package/src/database/migrations/004_search.sql +505 -0
- package/src/database/migrations/005_entities.sql +511 -0
- package/src/database/migrations/006_auth.sql +83 -0
- package/src/database/migrations/007_events.sql +136 -0
- package/src/database/migrations/008_hello.sql +18 -0
- package/src/database/migrations/008a_meta.sql +165 -0
- package/src/index.js +19 -0
- package/src/server/api.js +9 -0
- package/src/server/db.js +261 -0
- package/src/server/index.js +141 -0
- package/src/server/logger.js +246 -0
- package/src/server/mcp.js +594 -0
- package/src/server/meta-route.js +251 -0
- package/src/server/ws.js +464 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
-- DZQL Framework - Event System and Real-time Notifications
|
|
2
|
+
-- Events are created ONLY by API operations (generic_save, generic_delete)
|
|
3
|
+
-- This ensures proper user context and security
|
|
4
|
+
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
-- NOTIFY EVENT FUNCTION
|
|
7
|
+
-- ============================================================================
|
|
8
|
+
-- Event notification trigger - handles all real-time notifications
|
|
9
|
+
CREATE OR REPLACE FUNCTION dzql.notify_event()
|
|
10
|
+
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|
11
|
+
BEGIN
|
|
12
|
+
-- Send real-time notification to single channel
|
|
13
|
+
-- For DELETE operations, send the 'before' data since 'after' is NULL
|
|
14
|
+
PERFORM pg_notify('dzql', jsonb_build_object(
|
|
15
|
+
'event_id', NEW.event_id,
|
|
16
|
+
'table', NEW.table_name,
|
|
17
|
+
'op', NEW.op,
|
|
18
|
+
'pk', NEW.pk,
|
|
19
|
+
'data', COALESCE(NEW.after, NEW.before),
|
|
20
|
+
'before', NEW.before,
|
|
21
|
+
'after', NEW.after,
|
|
22
|
+
'user_id', NEW.user_id,
|
|
23
|
+
'at', NEW.at,
|
|
24
|
+
'notify_users', NEW.notify_users
|
|
25
|
+
)::text);
|
|
26
|
+
|
|
27
|
+
RETURN NULL;
|
|
28
|
+
END $$;
|
|
29
|
+
|
|
30
|
+
-- ============================================================================
|
|
31
|
+
-- CREATE TRIGGER ON EVENTS TABLE
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
-- Create trigger on events table to handle notifications
|
|
34
|
+
DROP TRIGGER IF EXISTS dzql_events_notify ON dzql.events;
|
|
35
|
+
CREATE TRIGGER dzql_events_notify
|
|
36
|
+
AFTER INSERT ON dzql.events
|
|
37
|
+
FOR EACH ROW EXECUTE FUNCTION dzql.notify_event();
|
|
38
|
+
|
|
39
|
+
-- ============================================================================
|
|
40
|
+
-- HELPER FUNCTIONS
|
|
41
|
+
-- ============================================================================
|
|
42
|
+
|
|
43
|
+
-- Get event history for a specific record (audit trail)
|
|
44
|
+
CREATE OR REPLACE FUNCTION dzql.get_record_history(
|
|
45
|
+
p_table_name text,
|
|
46
|
+
p_record_id text,
|
|
47
|
+
p_limit int DEFAULT 50
|
|
48
|
+
) RETURNS jsonb
|
|
49
|
+
LANGUAGE sql STABLE AS $$
|
|
50
|
+
SELECT COALESCE(jsonb_agg(
|
|
51
|
+
to_jsonb(e) ORDER BY e.at DESC
|
|
52
|
+
), '[]'::jsonb)
|
|
53
|
+
FROM (
|
|
54
|
+
SELECT * FROM dzql.events e
|
|
55
|
+
WHERE e.table_name = p_table_name
|
|
56
|
+
AND e.pk->>'id' = p_record_id
|
|
57
|
+
ORDER BY e.at DESC
|
|
58
|
+
LIMIT p_limit
|
|
59
|
+
) e;
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
-- Get recent actions by a user
|
|
63
|
+
CREATE OR REPLACE FUNCTION dzql.get_user_actions(
|
|
64
|
+
p_user_id int,
|
|
65
|
+
p_limit int DEFAULT 100
|
|
66
|
+
) RETURNS jsonb
|
|
67
|
+
LANGUAGE sql STABLE AS $$
|
|
68
|
+
SELECT COALESCE(jsonb_agg(
|
|
69
|
+
to_jsonb(e) ORDER BY e.at DESC
|
|
70
|
+
), '[]'::jsonb)
|
|
71
|
+
FROM (
|
|
72
|
+
SELECT * FROM dzql.events e
|
|
73
|
+
WHERE e.user_id = p_user_id
|
|
74
|
+
ORDER BY e.at DESC
|
|
75
|
+
LIMIT p_limit
|
|
76
|
+
) e;
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
-- Get event catchup data for synchronization
|
|
80
|
+
CREATE OR REPLACE FUNCTION dzql.catchup(p_context_id text, p_since_event_id bigint)
|
|
81
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
82
|
+
SELECT coalesce(jsonb_agg(to_jsonb(e) order by e.event_id), '[]'::jsonb)
|
|
83
|
+
FROM dzql.events e
|
|
84
|
+
WHERE e.event_id > p_since_event_id;
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
-- Get single event by ID
|
|
88
|
+
CREATE OR REPLACE FUNCTION dzql.get_event(p_event_id bigint)
|
|
89
|
+
RETURNS jsonb LANGUAGE sql STABLE AS $$
|
|
90
|
+
SELECT to_jsonb(e) FROM dzql.events e WHERE e.event_id = p_event_id;
|
|
91
|
+
$$;
|
|
92
|
+
|
|
93
|
+
-- Get recent events on a table
|
|
94
|
+
CREATE OR REPLACE FUNCTION dzql.get_table_events(
|
|
95
|
+
p_table_name text,
|
|
96
|
+
p_limit int DEFAULT 100
|
|
97
|
+
) RETURNS jsonb
|
|
98
|
+
LANGUAGE sql STABLE AS $$
|
|
99
|
+
SELECT COALESCE(jsonb_agg(
|
|
100
|
+
to_jsonb(e) ORDER BY e.at DESC
|
|
101
|
+
), '[]'::jsonb)
|
|
102
|
+
FROM (
|
|
103
|
+
SELECT * FROM dzql.events e
|
|
104
|
+
WHERE e.table_name = p_table_name
|
|
105
|
+
ORDER BY e.at DESC
|
|
106
|
+
LIMIT p_limit
|
|
107
|
+
) e;
|
|
108
|
+
$$;
|
|
109
|
+
|
|
110
|
+
-- Comments
|
|
111
|
+
COMMENT ON FUNCTION dzql.notify_event() IS 'Broadcasts event notifications via PostgreSQL NOTIFY for real-time updates';
|
|
112
|
+
COMMENT ON FUNCTION dzql.get_record_history(text, text, int) IS 'Returns audit trail for a specific record';
|
|
113
|
+
COMMENT ON FUNCTION dzql.get_user_actions(int, int) IS 'Returns recent actions performed by a user';
|
|
114
|
+
COMMENT ON FUNCTION dzql.get_table_events(text, int) IS 'Returns recent events for a table';
|
|
115
|
+
|
|
116
|
+
-- ============================================================================
|
|
117
|
+
-- CLEANUP OLD TRIGGER SYSTEM
|
|
118
|
+
-- ============================================================================
|
|
119
|
+
-- Remove any table triggers that were created by the old system
|
|
120
|
+
DO $$
|
|
121
|
+
DECLARE
|
|
122
|
+
l_table_name text;
|
|
123
|
+
BEGIN
|
|
124
|
+
-- Remove old emit_row_change function if it exists
|
|
125
|
+
DROP FUNCTION IF EXISTS dzql.emit_row_change() CASCADE;
|
|
126
|
+
|
|
127
|
+
-- Clean up any existing table triggers from registered entities
|
|
128
|
+
FOR l_table_name IN
|
|
129
|
+
SELECT table_name FROM dzql.entities
|
|
130
|
+
LOOP
|
|
131
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I',
|
|
132
|
+
l_table_name || '_dzql_events', l_table_name);
|
|
133
|
+
END LOOP;
|
|
134
|
+
|
|
135
|
+
RAISE NOTICE 'Cleaned up old table trigger system';
|
|
136
|
+
END $$;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Hello World Function for Testing DZQL's Function Proxy
|
|
2
|
+
|
|
3
|
+
-- Create a simple hello world function
|
|
4
|
+
-- First parameter must be p_user_id for DZQL compatibility
|
|
5
|
+
create or replace function hello(p_user_id int, p_name text default 'World')
|
|
6
|
+
returns jsonb
|
|
7
|
+
language plpgsql
|
|
8
|
+
security definer
|
|
9
|
+
as $$
|
|
10
|
+
begin
|
|
11
|
+
return jsonb_build_object(
|
|
12
|
+
'message', 'Hello, ' || coalesce(p_name, 'World') || '!',
|
|
13
|
+
'timestamp', now(),
|
|
14
|
+
'from', 'PostgreSQL',
|
|
15
|
+
'user_id', p_user_id
|
|
16
|
+
);
|
|
17
|
+
end;
|
|
18
|
+
$$;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
-- === Entity Metadata Function ===
|
|
2
|
+
-- Returns complete metadata for all registered entities in the DZQL system
|
|
3
|
+
-- Includes entity config, schema information, and relationship graph
|
|
4
|
+
-- Used by UI to dynamically configure based on registered entities
|
|
5
|
+
|
|
6
|
+
create or replace function get_entities_metadata(p_user_id int)
|
|
7
|
+
returns jsonb
|
|
8
|
+
language plpgsql
|
|
9
|
+
security definer
|
|
10
|
+
as $$
|
|
11
|
+
declare
|
|
12
|
+
v_entities jsonb;
|
|
13
|
+
v_relations jsonb;
|
|
14
|
+
v_junction_tables text[];
|
|
15
|
+
v_entity_names text[];
|
|
16
|
+
begin
|
|
17
|
+
-- Get list of registered entity names
|
|
18
|
+
select array_agg(table_name) into v_entity_names
|
|
19
|
+
from dzql.entities;
|
|
20
|
+
|
|
21
|
+
-- Build entities object with schema information
|
|
22
|
+
select jsonb_object_agg(
|
|
23
|
+
e.table_name,
|
|
24
|
+
jsonb_build_object(
|
|
25
|
+
'table_name', e.table_name,
|
|
26
|
+
'label_field', e.label_field,
|
|
27
|
+
'searchable_fields', e.searchable_fields,
|
|
28
|
+
'fk_includes', e.fk_includes,
|
|
29
|
+
'soft_delete', e.soft_delete,
|
|
30
|
+
'temporal_fields', e.temporal_fields,
|
|
31
|
+
'notification_paths', e.notification_paths,
|
|
32
|
+
'permission_paths', e.permission_paths,
|
|
33
|
+
'schema', (
|
|
34
|
+
-- Get column schema from information_schema
|
|
35
|
+
select jsonb_agg(
|
|
36
|
+
jsonb_build_object(
|
|
37
|
+
'column_name', c.column_name,
|
|
38
|
+
'data_type', c.data_type,
|
|
39
|
+
'is_nullable', c.is_nullable = 'YES',
|
|
40
|
+
'column_default', c.column_default,
|
|
41
|
+
'character_maximum_length', c.character_maximum_length,
|
|
42
|
+
'numeric_precision', c.numeric_precision,
|
|
43
|
+
'numeric_scale', c.numeric_scale,
|
|
44
|
+
'ordinal_position', c.ordinal_position
|
|
45
|
+
) order by c.ordinal_position
|
|
46
|
+
)
|
|
47
|
+
from information_schema.columns c
|
|
48
|
+
where c.table_schema = 'public'
|
|
49
|
+
and c.table_name = e.table_name
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
) into v_entities
|
|
53
|
+
from dzql.entities e;
|
|
54
|
+
|
|
55
|
+
-- Identify junction tables (2+ foreign keys, minimal searchable fields)
|
|
56
|
+
select array_agg(distinct tc.table_name) into v_junction_tables
|
|
57
|
+
from information_schema.table_constraints tc
|
|
58
|
+
where tc.constraint_type = 'FOREIGN KEY'
|
|
59
|
+
and tc.table_schema = 'public'
|
|
60
|
+
and tc.table_name = any(v_entity_names)
|
|
61
|
+
group by tc.table_name
|
|
62
|
+
having count(*) >= 2
|
|
63
|
+
and (
|
|
64
|
+
select count(*)
|
|
65
|
+
from unnest((
|
|
66
|
+
select searchable_fields
|
|
67
|
+
from dzql.entities
|
|
68
|
+
where table_name = tc.table_name
|
|
69
|
+
)) as sf
|
|
70
|
+
where sf not in (
|
|
71
|
+
select kcu.column_name
|
|
72
|
+
from information_schema.key_column_usage kcu
|
|
73
|
+
where kcu.table_name = tc.table_name
|
|
74
|
+
and kcu.constraint_name in (
|
|
75
|
+
select constraint_name
|
|
76
|
+
from information_schema.table_constraints
|
|
77
|
+
where table_name = tc.table_name
|
|
78
|
+
and constraint_type = 'FOREIGN KEY'
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
) <= 1;
|
|
82
|
+
|
|
83
|
+
-- Build relations array
|
|
84
|
+
with fk_relations as (
|
|
85
|
+
-- Get all foreign key relationships
|
|
86
|
+
select
|
|
87
|
+
tc.table_name,
|
|
88
|
+
kcu.column_name,
|
|
89
|
+
ccu.table_name as foreign_table_name,
|
|
90
|
+
ccu.column_name as foreign_column_name
|
|
91
|
+
from information_schema.table_constraints tc
|
|
92
|
+
join information_schema.key_column_usage kcu
|
|
93
|
+
on tc.constraint_name = kcu.constraint_name
|
|
94
|
+
and tc.table_schema = kcu.table_schema
|
|
95
|
+
join information_schema.constraint_column_usage ccu
|
|
96
|
+
on ccu.constraint_name = tc.constraint_name
|
|
97
|
+
and ccu.table_schema = tc.table_schema
|
|
98
|
+
where tc.constraint_type = 'FOREIGN KEY'
|
|
99
|
+
and tc.table_schema = 'public'
|
|
100
|
+
and tc.table_name = any(v_entity_names)
|
|
101
|
+
and ccu.table_name = any(v_entity_names)
|
|
102
|
+
),
|
|
103
|
+
simple_relations as (
|
|
104
|
+
-- Many-to-one and one-to-many for non-junction tables
|
|
105
|
+
select jsonb_build_object(
|
|
106
|
+
'type', 'many_to_one',
|
|
107
|
+
'from', fk.table_name || '.' || fk.column_name,
|
|
108
|
+
'to', fk.foreign_table_name || '.' || fk.foreign_column_name
|
|
109
|
+
) as relation
|
|
110
|
+
from fk_relations fk
|
|
111
|
+
where not (fk.table_name = any(v_junction_tables))
|
|
112
|
+
|
|
113
|
+
union all
|
|
114
|
+
|
|
115
|
+
select jsonb_build_object(
|
|
116
|
+
'type', 'one_to_many',
|
|
117
|
+
'from', fk.foreign_table_name || '.' || fk.foreign_column_name,
|
|
118
|
+
'to', fk.table_name || '.' || fk.column_name
|
|
119
|
+
) as relation
|
|
120
|
+
from fk_relations fk
|
|
121
|
+
where not (fk.table_name = any(v_junction_tables))
|
|
122
|
+
),
|
|
123
|
+
junction_relations as (
|
|
124
|
+
-- Many-to-many through junction tables
|
|
125
|
+
select jsonb_build_object(
|
|
126
|
+
'type', 'many_to_many',
|
|
127
|
+
'from', fk1.foreign_table_name || '.' || fk1.foreign_column_name,
|
|
128
|
+
'to', fk2.foreign_table_name || '.' || fk2.foreign_column_name,
|
|
129
|
+
'via', fk1.table_name || '.' || fk1.column_name || '.' || fk2.column_name
|
|
130
|
+
) as relation
|
|
131
|
+
from fk_relations fk1
|
|
132
|
+
join fk_relations fk2
|
|
133
|
+
on fk1.table_name = fk2.table_name
|
|
134
|
+
and fk1.column_name < fk2.column_name -- avoid duplicates
|
|
135
|
+
where fk1.table_name = any(v_junction_tables)
|
|
136
|
+
|
|
137
|
+
union all
|
|
138
|
+
|
|
139
|
+
select jsonb_build_object(
|
|
140
|
+
'type', 'many_to_many',
|
|
141
|
+
'from', fk2.foreign_table_name || '.' || fk2.foreign_column_name,
|
|
142
|
+
'to', fk1.foreign_table_name || '.' || fk1.foreign_column_name,
|
|
143
|
+
'via', fk1.table_name || '.' || fk2.column_name || '.' || fk1.column_name
|
|
144
|
+
) as relation
|
|
145
|
+
from fk_relations fk1
|
|
146
|
+
join fk_relations fk2
|
|
147
|
+
on fk1.table_name = fk2.table_name
|
|
148
|
+
and fk1.column_name < fk2.column_name
|
|
149
|
+
where fk1.table_name = any(v_junction_tables)
|
|
150
|
+
)
|
|
151
|
+
select jsonb_agg(relation) into v_relations
|
|
152
|
+
from (
|
|
153
|
+
select relation from simple_relations
|
|
154
|
+
union all
|
|
155
|
+
select relation from junction_relations
|
|
156
|
+
) all_relations;
|
|
157
|
+
|
|
158
|
+
-- Return complete metadata structure
|
|
159
|
+
return jsonb_build_object(
|
|
160
|
+
'entities', v_entities,
|
|
161
|
+
'relations', coalesce(v_relations, '[]'::jsonb),
|
|
162
|
+
'operations', jsonb_build_array('get', 'save', 'delete', 'lookup', 'search')
|
|
163
|
+
);
|
|
164
|
+
end;
|
|
165
|
+
$$;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ZeroQL Framework - Main Entry Point
|
|
2
|
+
export { createServer } from './server/index.js';
|
|
3
|
+
|
|
4
|
+
// Re-export client utilities
|
|
5
|
+
export { WebSocketManager, useWs } from './client/ws.js';
|
|
6
|
+
|
|
7
|
+
// Re-export UI framework
|
|
8
|
+
export { mount, state, Component } from './client/ui.js';
|
|
9
|
+
export { loadUI, loadEntityUI } from './client/ui-loader.js';
|
|
10
|
+
|
|
11
|
+
// Re-export database utilities for tests and custom functions
|
|
12
|
+
export { sql, listen_sql, db } from './server/db.js';
|
|
13
|
+
export { createWebSocketHandlers, verify_jwt_token } from './server/ws.js';
|
|
14
|
+
|
|
15
|
+
// Re-export meta route for applications
|
|
16
|
+
export { metaRoute } from './server/meta-route.js';
|
|
17
|
+
|
|
18
|
+
// Re-export MCP route for Claude Code integration
|
|
19
|
+
export { createMCPRoute } from './server/mcp.js';
|
package/src/server/db.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import { dbLogger, notifyLogger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
// Environment configuration
|
|
5
|
+
const DATABASE_URL =
|
|
6
|
+
process.env.DATABASE_URL ||
|
|
7
|
+
"postgresql://dzql:dzql@localhost:5432/dzql";
|
|
8
|
+
|
|
9
|
+
const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS || "10", 10);
|
|
10
|
+
const DB_IDLE_TIMEOUT = parseInt(process.env.DB_IDLE_TIMEOUT || "20", 10);
|
|
11
|
+
const DB_CONNECT_TIMEOUT = parseInt(process.env.DB_CONNECT_TIMEOUT || "10", 10);
|
|
12
|
+
|
|
13
|
+
// Main PostgreSQL connection for queries
|
|
14
|
+
export const sql = postgres(DATABASE_URL, {
|
|
15
|
+
max: DB_MAX_CONNECTIONS,
|
|
16
|
+
idle_timeout: DB_IDLE_TIMEOUT,
|
|
17
|
+
connect_timeout: DB_CONNECT_TIMEOUT,
|
|
18
|
+
// Suppress NOTICE messages in test environment
|
|
19
|
+
onnotice: process.env.NODE_ENV === 'test' ? () => {} : undefined,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Separate PostgreSQL connection for NOTIFY/LISTEN
|
|
23
|
+
export const listen_sql = postgres(DATABASE_URL, {
|
|
24
|
+
max: 1,
|
|
25
|
+
idle_timeout: 0,
|
|
26
|
+
connect_timeout: DB_CONNECT_TIMEOUT,
|
|
27
|
+
// Suppress NOTICE messages in test environment
|
|
28
|
+
onnotice: process.env.NODE_ENV === 'test' ? () => {} : undefined,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
dbLogger.info(`Database connected: ${DATABASE_URL.replace(/\/\/.*@/, '//***@')}`);
|
|
32
|
+
|
|
33
|
+
// Cache for function parameter metadata
|
|
34
|
+
const functionParamCache = new Map();
|
|
35
|
+
|
|
36
|
+
// Cache helpers
|
|
37
|
+
export async function getCache(key, ttlHours) {
|
|
38
|
+
const result = await sql`SELECT app._get_cache(${key}, ${ttlHours}) as data`;
|
|
39
|
+
return result[0]?.data ? JSON.parse(result[0].data) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function setCache(key, data) {
|
|
43
|
+
await sql`SELECT app._set_cache(${key}, ${JSON.stringify(data)})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Auth helpers
|
|
47
|
+
export async function callAuthFunction(method, email, password) {
|
|
48
|
+
const result = await sql`
|
|
49
|
+
SELECT ${sql(method)}(${email}, ${password}) as result
|
|
50
|
+
`;
|
|
51
|
+
return result[0].result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Get function parameter metadata
|
|
55
|
+
async function getFunctionParams(functionName) {
|
|
56
|
+
if (functionParamCache.has(functionName)) {
|
|
57
|
+
return functionParamCache.get(functionName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = await sql`
|
|
61
|
+
SELECT
|
|
62
|
+
p.parameter_name,
|
|
63
|
+
p.parameter_default,
|
|
64
|
+
p.data_type,
|
|
65
|
+
p.ordinal_position
|
|
66
|
+
FROM information_schema.parameters p
|
|
67
|
+
WHERE p.specific_name IN (
|
|
68
|
+
SELECT r.specific_name
|
|
69
|
+
FROM information_schema.routines r
|
|
70
|
+
WHERE r.routine_name = ${functionName}
|
|
71
|
+
AND r.routine_type = 'FUNCTION'
|
|
72
|
+
)
|
|
73
|
+
AND (p.parameter_mode = 'IN' OR p.parameter_mode IS NULL)
|
|
74
|
+
ORDER BY p.ordinal_position
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const params = result.map((row) => ({
|
|
78
|
+
name: row.parameter_name,
|
|
79
|
+
type: row.data_type,
|
|
80
|
+
position: row.ordinal_position,
|
|
81
|
+
hasDefault: row.parameter_default !== null,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
functionParamCache.set(functionName, params);
|
|
85
|
+
return params;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generic stored function call with user_id
|
|
89
|
+
export async function callUserFunction(method, userId, params) {
|
|
90
|
+
// Validate function name format (only alphanumeric and underscore, no special chars)
|
|
91
|
+
// This prevents SQL injection via function names like "foo(); DROP TABLE users--"
|
|
92
|
+
if (!/^[a-z_][a-z0-9_]*$/i.test(method)) {
|
|
93
|
+
throw new Error(`Invalid function name: ${method}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const functionParams = await getFunctionParams(method);
|
|
97
|
+
|
|
98
|
+
if (functionParams.length === 0) {
|
|
99
|
+
throw new Error(`Function ${method} not found`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build ordered parameter array
|
|
103
|
+
const orderedParams = [];
|
|
104
|
+
|
|
105
|
+
for (const param of functionParams) {
|
|
106
|
+
if (param.position === 1) {
|
|
107
|
+
// First parameter is always user_id
|
|
108
|
+
orderedParams.push(userId);
|
|
109
|
+
} else {
|
|
110
|
+
// Strip p_ prefix from parameter name for client API matching
|
|
111
|
+
const clientParamName = param.name.startsWith("p_")
|
|
112
|
+
? param.name.substring(2)
|
|
113
|
+
: param.name;
|
|
114
|
+
|
|
115
|
+
if (params && params[clientParamName] !== undefined) {
|
|
116
|
+
// Parameter exists in the params object
|
|
117
|
+
orderedParams.push(params[clientParamName]);
|
|
118
|
+
} else if (param.hasDefault) {
|
|
119
|
+
// Parameter has a default value, skip it
|
|
120
|
+
break;
|
|
121
|
+
} else {
|
|
122
|
+
// Required parameter missing
|
|
123
|
+
throw new Error(`Missing required parameter: ${clientParamName}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Try table format first - works for both single and multiple results
|
|
129
|
+
const query = `SELECT * FROM ${method}(${orderedParams.map((_, i) => `$${i + 1}`).join(", ")})`;
|
|
130
|
+
const result = await sql.unsafe(query, orderedParams);
|
|
131
|
+
|
|
132
|
+
// If single row with single column, return just the value
|
|
133
|
+
if (result.length === 1 && Object.keys(result[0]).length === 1) {
|
|
134
|
+
return Object.values(result[0])[0];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Otherwise return the full result set
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Get user profile
|
|
142
|
+
export async function getUserProfile(userId) {
|
|
143
|
+
const result = await sql`
|
|
144
|
+
SELECT _profile(${userId}::integer) as profile
|
|
145
|
+
`;
|
|
146
|
+
return result[0].profile;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Setup NOTIFY listeners
|
|
150
|
+
export async function setupListeners(callback) {
|
|
151
|
+
try {
|
|
152
|
+
// Listen to single dzql channel for all events
|
|
153
|
+
await listen_sql.listen("dzql", (payload) => {
|
|
154
|
+
const event = JSON.parse(payload);
|
|
155
|
+
notifyLogger.debug(`Received NOTIFY event:`, event.table, event.op);
|
|
156
|
+
callback(event);
|
|
157
|
+
});
|
|
158
|
+
notifyLogger.info("NOTIFY listener established on 'dzql' channel");
|
|
159
|
+
return true;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
notifyLogger.error("Failed to setup listeners:", error.message);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// DZQL Generic Operations
|
|
167
|
+
export async function callDZQLOperation(operation, entity, args, userId) {
|
|
168
|
+
dbLogger.trace(`DZQL ${operation}.${entity} for user ${userId}`);
|
|
169
|
+
const result = await sql`
|
|
170
|
+
SELECT dzql.generic_exec(${operation}, ${entity}, ${args}, ${userId}) as result
|
|
171
|
+
`;
|
|
172
|
+
return result[0].result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// DZQL nested proxy factory
|
|
176
|
+
function createEntityProxy(operation) {
|
|
177
|
+
return new Proxy(
|
|
178
|
+
{},
|
|
179
|
+
{
|
|
180
|
+
get(target, entityName) {
|
|
181
|
+
return async (args = {}, userId) => {
|
|
182
|
+
// userId is required for DZQL operations
|
|
183
|
+
if (!userId) {
|
|
184
|
+
throw new Error("userId is required for DZQL operations");
|
|
185
|
+
}
|
|
186
|
+
return callDZQLOperation(operation, entityName, args, userId);
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// DZQL database API proxy with custom function support
|
|
194
|
+
export const db = {
|
|
195
|
+
api: new Proxy(
|
|
196
|
+
{
|
|
197
|
+
get: createEntityProxy("get"),
|
|
198
|
+
save: createEntityProxy("save"),
|
|
199
|
+
delete: createEntityProxy("delete"),
|
|
200
|
+
lookup: createEntityProxy("lookup"),
|
|
201
|
+
search: createEntityProxy("search"),
|
|
202
|
+
exec: async (functionName, args, userId) => {
|
|
203
|
+
if (!userId) {
|
|
204
|
+
throw new Error("userId is required for function calls");
|
|
205
|
+
}
|
|
206
|
+
return callUserFunction(functionName, userId, args);
|
|
207
|
+
},
|
|
208
|
+
// Permission and path resolution utilities
|
|
209
|
+
checkPermission: async (userId, operation, entity, record) => {
|
|
210
|
+
const result = await sql`
|
|
211
|
+
SELECT dzql.check_permission(${userId}, ${operation}, ${entity}, ${JSON.stringify(record)}) as allowed
|
|
212
|
+
`;
|
|
213
|
+
return result[0].allowed;
|
|
214
|
+
},
|
|
215
|
+
resolveNotificationPath: async (tableName, record, path) => {
|
|
216
|
+
const result = await sql`
|
|
217
|
+
SELECT dzql.resolve_notification_path(${tableName}, ${JSON.stringify(record)}, ${path}) as user_ids
|
|
218
|
+
`;
|
|
219
|
+
return result[0].user_ids;
|
|
220
|
+
},
|
|
221
|
+
resolveNotificationPaths: async (tableName, record) => {
|
|
222
|
+
const result = await sql`
|
|
223
|
+
SELECT dzql.resolve_notification_paths(${tableName}, ${JSON.stringify(record)}) as user_ids
|
|
224
|
+
`;
|
|
225
|
+
return result[0].user_ids;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
get(target, prop) {
|
|
230
|
+
// Return existing DZQL operations
|
|
231
|
+
if (target[prop]) {
|
|
232
|
+
return target[prop];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Handle custom functions
|
|
236
|
+
return async (userIdOrArgs, args = {}) => {
|
|
237
|
+
// Special handling for auth functions that don't require userId
|
|
238
|
+
if (prop === 'register_user' || prop === 'login_user') {
|
|
239
|
+
// For auth functions, first param is the args object
|
|
240
|
+
return callAuthFunction(prop, userIdOrArgs.email, userIdOrArgs.password);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// For other functions, userId is required as first parameter
|
|
244
|
+
if (!userIdOrArgs) {
|
|
245
|
+
throw new Error(`userId is required for function ${prop}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return callUserFunction(prop, userIdOrArgs, args);
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Graceful shutdown
|
|
256
|
+
export async function closeConnections() {
|
|
257
|
+
dbLogger.info("Closing database connections...");
|
|
258
|
+
await sql.end();
|
|
259
|
+
await listen_sql.end();
|
|
260
|
+
dbLogger.info("Database connections closed");
|
|
261
|
+
}
|